Initial version of smartbooking generated by generator-jhipster@9.0.0-beta.0

This commit is contained in:
2025-12-10 16:41:34 +01:00
commit e4b8486f4b
376 changed files with 44072 additions and 0 deletions

58
src/main/webapp/404.html Normal file
View File

@@ -0,0 +1,58 @@
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<title>Page Not Found</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="favicon.ico" />
<style>
* {
line-height: 1.2;
margin: 0;
}
html {
color: #888;
display: table;
font-family: sans-serif;
height: 100%;
text-align: center;
width: 100%;
}
body {
display: table-cell;
vertical-align: middle;
margin: 2em auto;
}
h1 {
color: #555;
font-size: 2em;
font-weight: 400;
}
p {
margin: 0 auto;
width: 280px;
}
@media only screen and (max-width: 280px) {
body,
p {
width: 95%;
}
h1 {
font-size: 1.5em;
margin: 0 0 0.3em;
}
}
</style>
</head>
<body>
<h1>Page Not Found</h1>
<p>Sorry, but the page you were trying to view does not exist.</p>
</body>
</html>
<!-- IE needs 512+ bytes: http://blogs.msdn.com/b/ieinternals/archive/2010/08/19/http-error-pages-in-internet-explorer.aspx -->

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<web-app
xmlns="http://java.sun.com/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
version="3.0">
<mime-mapping>
<extension>html</extension>
<mime-type>text/html;charset=utf-8</mime-type>
</mime-mapping>
</web-app>

View File

@@ -0,0 +1,110 @@
import { createTestingPinia } from '@pinia/testing';
import axios from 'axios';
import sinon from 'sinon';
import { type AccountStore, useStore } from '@/store';
import AccountService from './account.service';
const resetStore = (store: AccountStore) => {
store.$reset();
};
const axiosStub = {
get: sinon.stub(axios, 'get'),
post: sinon.stub(axios, 'post'),
};
createTestingPinia({ stubActions: false });
const store = useStore();
describe('Account Service test suite', () => {
let accountService: AccountService;
beforeEach(() => {
localStorage.clear();
axiosStub.get.reset();
resetStore(store);
});
it('should init service and do not retrieve account', async () => {
axiosStub.get.resolves({});
axiosStub.get
.withArgs('management/info')
.resolves({ status: 200, data: { 'display-ribbon-on-profiles': 'dev', activeProfiles: ['dev', 'test'] } });
accountService = new AccountService(store);
await accountService.update();
expect(store.logon).toBe(null);
expect(accountService.authenticated).toBe(false);
expect(store.account).toBe(null);
expect(axiosStub.get.calledWith('management/info')).toBeTruthy();
expect(store.activeProfiles[0]).toBe('dev');
expect(store.activeProfiles[1]).toBe('test');
expect(store.ribbonOnProfiles).toBe('dev');
});
it('should init service and retrieve profiles if already logged in before but no account found', async () => {
axiosStub.get.resolves({});
accountService = new AccountService(store);
await accountService.update();
expect(store.logon).toBe(null);
expect(accountService.authenticated).toBe(false);
expect(store.account).toBe(null);
expect(axiosStub.get.calledWith('management/info')).toBeTruthy();
});
it('should init service and retrieve profiles if already logged in before but exception occurred and should be logged out', async () => {
axiosStub.get.resolves({});
axiosStub.get.withArgs('api/account').rejects();
accountService = new AccountService(store);
await accountService.update();
expect(accountService.authenticated).toBe(false);
expect(store.account).toBe(null);
expect(axiosStub.get.calledWith('management/info')).toBeTruthy();
});
it('should init service and check for authority after retrieving account but getAccount failed', async () => {
axiosStub.get.rejects();
accountService = new AccountService(store);
await accountService.update();
return accountService.hasAnyAuthorityAndCheckAuth('USER').then((value: boolean) => {
expect(value).toBe(false);
});
});
it('should init service and check for authority after retrieving account', async () => {
axiosStub.get.resolves({ status: 200, data: { authorities: ['USER'], langKey: 'en', login: 'ADMIN' } });
accountService = new AccountService(store);
await accountService.update();
return accountService.hasAnyAuthorityAndCheckAuth('USER').then((value: boolean) => {
expect(value).toBe(true);
});
});
it('should init service as not authenticated and not return any authorities admin and not retrieve account', async () => {
axiosStub.get.rejects();
accountService = new AccountService(store);
await accountService.update();
return accountService.hasAnyAuthorityAndCheckAuth('ADMIN').then((value: boolean) => {
expect(value).toBe(false);
});
});
it('should init service as not authenticated and return authority user', async () => {
axiosStub.get.rejects();
accountService = new AccountService(store);
await accountService.update();
return accountService.hasAnyAuthorityAndCheckAuth('USER').then((value: boolean) => {
expect(value).toBe(false);
});
});
});

View File

@@ -0,0 +1,85 @@
import axios from 'axios';
import { type AccountStore } from '@/store';
export default class AccountService {
constructor(private store: AccountStore) {}
async update(): Promise<void> {
if (!this.store.profilesLoaded) {
await this.retrieveProfiles();
this.store.setProfilesLoaded();
}
await this.loadAccount();
}
async retrieveProfiles(): Promise<boolean> {
try {
const res = await axios.get<any>('management/info');
if (res.data?.activeProfiles) {
this.store.setRibbonOnProfiles(res.data['display-ribbon-on-profiles']);
this.store.setActiveProfiles(res.data.activeProfiles);
}
return true;
} catch {
return false;
}
}
async retrieveAccount(): Promise<boolean> {
try {
const response = await axios.get<any>('api/account');
if (response.status === 200 && response.data?.login) {
const account = response.data;
this.store.setAuthentication(account);
return true;
}
} catch {
// Ignore error
}
this.store.logout();
return false;
}
async loadAccount() {
if (this.store.logon) {
return this.store.logon;
}
if (this.authenticated && this.userAuthorities) {
return;
}
const promise = this.retrieveAccount();
this.store.authenticate(promise);
promise.then(() => this.store.authenticate(null));
await promise;
}
async hasAnyAuthorityAndCheckAuth(authorities: any): Promise<boolean> {
if (typeof authorities === 'string') {
authorities = [authorities];
}
return this.checkAuthorities(authorities);
}
get authenticated(): boolean {
return this.store.authenticated;
}
get userAuthorities(): string[] {
return this.store.account?.authorities;
}
private checkAuthorities(authorities: string[]): boolean {
if (this.userAuthorities) {
for (const authority of authorities) {
if (this.userAuthorities.includes(authority)) {
return true;
}
}
}
return false;
}
}

View File

@@ -0,0 +1,60 @@
import { vitest } from 'vitest';
import { createTestingPinia } from '@pinia/testing';
import { type ComponentMountingOptions, shallowMount } from '@vue/test-utils';
import axios from 'axios';
import sinon from 'sinon';
import Activate from './activate.vue';
type ActivateComponentType = InstanceType<typeof Activate>;
const route = { query: { key: 'key' } };
vitest.mock('vue-router', () => ({
useRoute: () => route,
}));
const axiosStub = {
get: sinon.stub(axios, 'get'),
post: sinon.stub(axios, 'post'),
};
describe('Activate Component', () => {
let activate: ActivateComponentType;
let mountOptions: ComponentMountingOptions<ActivateComponentType>;
beforeEach(() => {
mountOptions = {
global: {
plugins: [createTestingPinia()],
},
};
});
afterAll(() => {
sinon.restore();
});
it('should display error when activation fails', async () => {
axiosStub.get.rejects({});
const wrapper = shallowMount(Activate as any, mountOptions);
activate = wrapper.vm;
await activate.$nextTick();
expect(activate.error).toBeTruthy();
expect(activate.success).toBeFalsy();
});
it('should display success when activation succeeds', async () => {
axiosStub.get.resolves({});
const wrapper = shallowMount(Activate as any, mountOptions);
activate = wrapper.vm;
await activate.$nextTick();
expect(activate.error).toBeFalsy();
expect(activate.success).toBeTruthy();
});
});

View File

@@ -0,0 +1,38 @@
import { type Ref, defineComponent, inject, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useLoginModal } from '@/account/login-modal';
import ActivateService from './activate.service';
export default defineComponent({
setup() {
const activateService = inject('activateService', () => new ActivateService(), true);
const { showLogin } = useLoginModal();
const route = useRoute();
const success: Ref<boolean> = ref(false);
const error: Ref<boolean> = ref(false);
onMounted(async () => {
const key = Array.isArray(route.query.key) ? route.query.key[0] : route.query.key;
try {
await activateService.activateAccount(key);
success.value = true;
error.value = false;
} catch {
error.value = true;
success.value = false;
}
});
return {
activateService,
showLogin,
success,
error,
t$: useI18n().t,
};
},
});

View File

@@ -0,0 +1,13 @@
import axios, { type AxiosInstance } from 'axios';
export default class ActivateService {
private axios: AxiosInstance;
constructor() {
this.axios = axios;
}
activateAccount(key: string): Promise<any> {
return this.axios.get(`api/activate?key=${key}`);
}
}

View File

@@ -0,0 +1,17 @@
<template>
<div>
<div class="d-flex justify-content-center">
<div class="col-md-8">
<h1>{{ t$('activate.title') }}</h1>
<div class="alert alert-success" v-if="success">
<span v-html="t$('activate.messages.success')"></span>
<a class="alert-link" @click="showLogin()">{{ t$('global.messages.info.authenticated.link') }}</a
>.
</div>
<div class="alert alert-danger" v-if="error" v-html="t$('activate.messages.error')"></div>
</div>
</div>
</div>
</template>
<script lang="ts" src="./activate.component.ts"></script>

View File

@@ -0,0 +1,85 @@
import { computed } from 'vue';
import { createTestingPinia } from '@pinia/testing';
import { shallowMount } from '@vue/test-utils';
import axios from 'axios';
import sinon from 'sinon';
import ChangePassword from './change-password.vue';
type ChangePasswordComponentType = InstanceType<typeof ChangePassword>;
const pinia = createTestingPinia();
const axiosStub = {
get: sinon.stub(axios, 'get'),
post: sinon.stub(axios, 'post'),
};
describe('ChangePassword Component', () => {
let changePassword: ChangePasswordComponentType;
beforeEach(() => {
axiosStub.get.resolves({});
axiosStub.post.reset();
const wrapper = shallowMount(ChangePassword, {
global: {
plugins: [pinia],
provide: {
currentUsername: computed(() => 'username'),
},
},
});
changePassword = wrapper.vm;
});
it('should show error if passwords do not match', () => {
// GIVEN
changePassword.resetPassword = { newPassword: 'password1', confirmPassword: 'password2' };
// WHEN
changePassword.changePassword();
// THEN
expect(changePassword.doNotMatch).toBe('ERROR');
expect(changePassword.error).toBeNull();
expect(changePassword.success).toBeNull();
});
it('should call Auth.changePassword when passwords match and set success to OK upon success', async () => {
// GIVEN
changePassword.resetPassword = { currentPassword: 'password1', newPassword: 'password1', confirmPassword: 'password1' };
axiosStub.post.resolves({});
// WHEN
changePassword.changePassword();
await changePassword.$nextTick();
// THEN
expect(
axiosStub.post.calledWith('api/account/change-password', {
currentPassword: 'password1',
newPassword: 'password1',
}),
).toBeTruthy();
expect(changePassword.doNotMatch).toBeNull();
expect(changePassword.error).toBeNull();
expect(changePassword.success).toBe('OK');
});
it('should notify of error if change password fails', async () => {
// GIVEN
changePassword.resetPassword = { currentPassword: 'password1', newPassword: 'password1', confirmPassword: 'password1' };
axiosStub.post.rejects({});
// WHEN
changePassword.changePassword();
await changePassword.$nextTick();
// THEN
expect(changePassword.doNotMatch).toBeNull();
expect(changePassword.success).toBeNull();
await changePassword.$nextTick();
expect(changePassword.error).toBe('ERROR');
});
});

View File

@@ -0,0 +1,71 @@
import { type ComputedRef, type Ref, defineComponent, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { maxLength, minLength, required, sameAs } from '@vuelidate/validators';
import axios from 'axios';
export default defineComponent({
validations() {
return {
resetPassword: {
currentPassword: {
required,
},
newPassword: {
required,
minLength: minLength(4),
maxLength: maxLength(254),
},
confirmPassword: {
sameAsPassword: sameAs(this.resetPassword.newPassword),
},
},
};
},
setup() {
const username = inject<ComputedRef<string>>('currentUsername');
const success: Ref<string> = ref(null);
const error: Ref<string> = ref(null);
const doNotMatch: Ref<string> = ref(null);
const resetPassword: Ref<any> = ref({
currentPassword: null,
newPassword: null,
confirmPassword: null,
});
return {
username,
success,
error,
doNotMatch,
resetPassword,
v$: useVuelidate(),
t$: useI18n().t,
};
},
methods: {
changePassword(): void {
if (this.resetPassword.newPassword !== this.resetPassword.confirmPassword) {
this.error = null;
this.success = null;
this.doNotMatch = 'ERROR';
} else {
this.doNotMatch = null;
axios
.post('api/account/change-password', {
currentPassword: this.resetPassword.currentPassword,
newPassword: this.resetPassword.newPassword,
})
.then(() => {
this.success = 'OK';
this.error = null;
})
.catch(() => {
this.success = null;
this.error = 'ERROR';
});
}
},
},
});

View File

@@ -0,0 +1,92 @@
<template>
<div>
<div class="d-flex justify-content-center">
<div class="col-md-8 toastify-container">
<h2 v-if="username" id="password-title">
<span v-html="t$('password.title', { username })"></span>
</h2>
<div class="alert alert-success" role="alert" v-if="success" v-html="t$('password.messages.success')"></div>
<div class="alert alert-danger" role="alert" v-if="error" v-html="t$('password.messages.error')"></div>
<div class="alert alert-danger" role="alert" v-if="doNotMatch">{{ t$('global.messages.error.dontmatch') }}</div>
<form name="form" id="password-form" @submit.prevent="changePassword()">
<div class="mb-3">
<label class="form-control-label" for="currentPassword">{{ t$("global.form['currentpassword.label']") }}</label>
<input
type="password"
class="form-control"
id="currentPassword"
name="currentPassword"
:class="{ 'is-valid': !v$.resetPassword.currentPassword.$invalid, 'is-invalid': v$.resetPassword.currentPassword.$invalid }"
:placeholder="t$('global.form[\'currentpassword.placeholder\']')"
v-model="v$.resetPassword.currentPassword.$model"
required
data-cy="currentPassword"
/>
<div v-if="v$.resetPassword.currentPassword.$anyDirty && v$.resetPassword.currentPassword.$invalid">
<small class="form-text text-danger" v-if="v$.resetPassword.currentPassword.required.$invalid">{{
t$('global.messages.validate.newpassword.required')
}}</small>
</div>
</div>
<div class="mb-3">
<label class="form-control-label" for="newPassword">{{ t$("global.form['newpassword.label']") }}</label>
<input
type="password"
class="form-control"
id="newPassword"
name="newPassword"
:placeholder="t$('global.form[\'newpassword.placeholder\']')"
:class="{ 'is-valid': !v$.resetPassword.newPassword.$invalid, 'is-invalid': v$.resetPassword.newPassword.$invalid }"
v-model="v$.resetPassword.newPassword.$model"
minlength="4"
maxlength="50"
required
data-cy="newPassword"
/>
<div v-if="v$.resetPassword.newPassword.$anyDirty && v$.resetPassword.newPassword.$invalid">
<small class="form-text text-danger" v-if="v$.resetPassword.newPassword.required.$invalid">{{
t$('global.messages.validate.newpassword.required')
}}</small>
<small class="form-text text-danger" v-if="v$.resetPassword.newPassword.minLength.$invalid">{{
t$('global.messages.validate.newpassword.minlength')
}}</small>
<small class="form-text text-danger" v-if="v$.resetPassword.newPassword.maxLength.$invalid">{{
t$('global.messages.validate.newpassword.maxlength')
}}</small>
</div>
</div>
<div class="mb-3">
<label class="form-control-label" for="confirmPassword">{{ t$("global.form['confirmpassword.label']") }}</label>
<input
type="password"
class="form-control"
id="confirmPassword"
name="confirmPassword"
:class="{ 'is-valid': !v$.resetPassword.confirmPassword.$invalid, 'is-invalid': v$.resetPassword.confirmPassword.$invalid }"
:placeholder="t$('global.form[\'confirmpassword.placeholder\']')"
v-model="v$.resetPassword.confirmPassword.$model"
minlength="4"
maxlength="50"
required
data-cy="confirmPassword"
/>
<div v-if="v$.resetPassword.confirmPassword.$anyDirty && v$.resetPassword.confirmPassword.$invalid">
<small class="form-text text-danger" v-if="v$.resetPassword.confirmPassword.sameAsPassword.$invalid">{{
t$('global.messages.error.dontmatch')
}}</small>
</div>
</div>
<button type="submit" :disabled="v$.resetPassword.$invalid" class="btn btn-primary" data-cy="submit">
{{ t$('password.form.button') }}
</button>
</form>
</div>
</div>
</div>
</template>
<script lang="ts" src="./change-password.component.ts"></script>

View File

@@ -0,0 +1,100 @@
import { vitest } from 'vitest';
import { type RouteLocation } from 'vue-router';
import { createTestingPinia } from '@pinia/testing';
import { type MountingOptions, shallowMount } from '@vue/test-utils';
import axios from 'axios';
import sinon from 'sinon';
import { useStore } from '@/store';
import AccountService from '../account.service';
import LoginForm from './login-form.vue';
type LoginFormComponentType = InstanceType<typeof LoginForm>;
let route: Partial<RouteLocation>;
const routerGoMock = vitest.fn();
vitest.mock('vue-router', () => ({
useRoute: () => route,
useRouter: () => ({ go: routerGoMock }),
}));
const axiosStub = {
get: sinon.stub(axios, 'get'),
post: sinon.stub(axios, 'post'),
};
describe('LoginForm Component', () => {
let loginForm: LoginFormComponentType;
beforeEach(() => {
route = {};
axiosStub.get.resolves({});
axiosStub.post.reset();
const pinia = createTestingPinia();
const store = useStore();
const globalOptions: MountingOptions<LoginFormComponentType>['global'] = {
stubs: {
'b-alert': true,
'b-button': true,
'b-form': true,
'b-form-input': true,
'b-form-group': true,
'b-form-checkbox': true,
'b-link': true,
},
plugins: [pinia],
provide: {
accountService: new AccountService(store),
},
};
const wrapper = shallowMount(LoginForm, { global: globalOptions });
loginForm = wrapper.vm;
});
it('should authentication be KO', async () => {
// GIVEN
loginForm.login = 'login';
loginForm.password = 'pwd';
loginForm.rememberMe = true;
axiosStub.post.rejects();
// WHEN
loginForm.doLogin();
await loginForm.$nextTick();
// THEN
expect(
axiosStub.post.calledWith('api/authentication', 'username=login&password=pwd&remember-me=true&submit=Login', {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}),
).toBeTruthy();
await loginForm.$nextTick();
expect(loginForm.authenticationError).toBeTruthy();
});
it('should authentication be OK', async () => {
// GIVEN
loginForm.login = 'login';
loginForm.password = 'pwd';
loginForm.rememberMe = true;
axiosStub.post.resolves({});
// WHEN
loginForm.doLogin();
await loginForm.$nextTick();
// THEN
expect(
axiosStub.post.calledWith('api/authentication', 'username=login&password=pwd&remember-me=true&submit=Login', {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}),
).toBeTruthy();
expect(loginForm.authenticationError).toBeFalsy();
});
});

View File

@@ -0,0 +1,54 @@
import { type Ref, defineComponent, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { useLoginModal } from '@/account/login-modal';
import type AccountService from '../account.service';
export default defineComponent({
setup() {
const authenticationError: Ref<boolean> = ref(false);
const login: Ref<string> = ref(null);
const password: Ref<string> = ref(null);
const rememberMe: Ref<boolean> = ref(false);
const { hideLogin } = useLoginModal();
const route = useRoute();
const router = useRouter();
const previousState = () => router.go(-1);
const accountService = inject<AccountService>('accountService');
const doLogin = async () => {
const data = `username=${encodeURIComponent(login.value)}&password=${encodeURIComponent(password.value)}&remember-me=${rememberMe.value}&submit=Login`;
try {
await axios.post('api/authentication', data, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
authenticationError.value = false;
hideLogin();
await accountService.retrieveAccount();
if (route.path === '/forbidden') {
previousState();
}
} catch {
authenticationError.value = true;
}
};
return {
authenticationError,
login,
password,
rememberMe,
accountService,
doLogin,
t$: useI18n().t,
};
},
});

View File

@@ -0,0 +1,62 @@
<template>
<div class="d-flex justify-content-center">
<div class="row-md">
<div class="col-md-12">
<b-alert
data-cy="loginError"
variant="danger"
:model-value="authenticationError"
v-html="t$('login.messages.error.authentication')"
></b-alert>
</div>
<div class="col-md-12">
<b-form @submit.prevent="doLogin()">
<b-form-group :label="t$('global.form[\'username.label\']')" label-for="username">
<b-form-input
id="username"
type="text"
name="username"
:placeholder="t$('global.form[\'username.placeholder\']')"
v-model="login"
data-cy="username"
>
</b-form-input>
</b-form-group>
<b-form-group :label="t$('login.form.password')" label-for="password">
<b-form-input
id="password"
type="password"
name="password"
:placeholder="t$('login.form[\'password.placeholder\']')"
v-model="password"
data-cy="password"
>
</b-form-input>
</b-form-group>
<b-form-checkbox id="rememberMe" name="rememberMe" v-model="rememberMe" checked>
<span>{{ t$('login.form.rememberme') }}</span>
</b-form-checkbox>
<div>
<b-button data-cy="submit" type="submit" variant="primary">{{ t$('login.form.button') }}</b-button>
</div>
</b-form>
<p></p>
<div>
<b-alert :model-value="true" variant="warning">
<b-link :to="'/account/reset/request'" class="alert-link" data-cy="forgetYourPasswordSelector">{{
t$('login.password.forgot')
}}</b-link>
</b-alert>
</div>
<div>
<b-alert :model-value="true" variant="warning">
<span>{{ t$('global.messages.info.register.noaccount') }}</span>
<b-link :to="'/register'" class="alert-link">{{ t$('global.messages.info.register.link') }}</b-link>
</b-alert>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" src="./login-form.component.ts"></script>

View File

@@ -0,0 +1,21 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
export const useLoginModal = defineStore('login', () => {
const loginModalOpen = ref(false);
function showLogin() {
loginModalOpen.value = true;
}
function hideLogin() {
loginModalOpen.value = false;
}
return {
loginModalOpen,
showLogin,
hideLogin,
};
});

View File

@@ -0,0 +1,23 @@
import axios from 'axios';
import sinon from 'sinon';
import LoginService from './login.service';
const axiosStub = {
get: sinon.stub(axios, 'get'),
post: sinon.stub(axios, 'post'),
};
describe('Login Service test suite', () => {
let loginService: LoginService;
beforeEach(() => {
loginService = new LoginService({ emit: vitest.fn() });
});
it('should call global logout when asked to', () => {
loginService.logout();
expect(axiosStub.post.calledWith('api/logout')).toBeTruthy();
});
});

View File

@@ -0,0 +1,7 @@
import axios, { type AxiosPromise } from 'axios';
export default class LoginService {
logout(): AxiosPromise<any> {
return axios.post('api/logout');
}
}

View File

@@ -0,0 +1,135 @@
import { computed } from 'vue';
import { createTestingPinia } from '@pinia/testing';
import { shallowMount } from '@vue/test-utils';
import axios from 'axios';
import sinon from 'sinon';
import { useLoginModal } from '@/account/login-modal';
import { EMAIL_ALREADY_USED_TYPE, LOGIN_ALREADY_USED_TYPE } from '@/constants';
import Register from './register.vue';
type RegisterComponentType = InstanceType<typeof Register>;
const axiosStub = {
get: sinon.stub(axios, 'get'),
post: sinon.stub(axios, 'post'),
};
describe('Register Component', () => {
let register: RegisterComponentType;
const filledRegisterAccount = {
email: 'jhi@pster.net',
langKey: 'it',
login: 'jhi',
password: 'jhipster',
};
beforeEach(() => {
axiosStub.get.resolves({});
axiosStub.post.reset();
const wrapper = shallowMount(Register, {
global: {
plugins: [createTestingPinia()],
provide: {
currentLanguage: computed(() => 'it'),
},
},
});
register = wrapper.vm;
});
it('should set all default values correctly', () => {
expect(register.success).toBe(false);
expect(register.error).toBe('');
expect(register.errorEmailExists).toBe('');
expect(register.errorUserExists).toBe('');
expect(register.confirmPassword).toBe(null);
expect(register.registerAccount.login).toBe(undefined);
expect(register.registerAccount.password).toBe(undefined);
expect(register.registerAccount.email).toBe(undefined);
});
it('should open login modal when asked to', () => {
const login = useLoginModal();
register.showLogin();
expect(login.showLogin).toHaveBeenCalledOnce();
});
it('should register when password match', async () => {
axiosStub.post.resolves();
register.registerAccount = filledRegisterAccount;
register.confirmPassword = filledRegisterAccount.password;
register.register();
await register.$nextTick();
expect(
axiosStub.post.calledWith('api/register', {
email: 'jhi@pster.net',
langKey: 'it',
login: 'jhi',
password: 'jhipster',
}),
).toBeTruthy();
expect(register.success).toBe(true);
expect(register.error).toBe(null);
expect(register.errorEmailExists).toBe(null);
expect(register.errorUserExists).toBe(null);
});
it('should register when password match but throw error when login already exist', async () => {
const error = { response: { status: 400, data: { type: LOGIN_ALREADY_USED_TYPE } } };
axiosStub.post.rejects(error);
register.registerAccount = filledRegisterAccount;
register.confirmPassword = filledRegisterAccount.password;
register.register();
await register.$nextTick();
expect(
axiosStub.post.calledWith('api/register', { email: 'jhi@pster.net', langKey: 'it', login: 'jhi', password: 'jhipster' }),
).toBeTruthy();
await register.$nextTick();
expect(register.success).toBe(null);
expect(register.error).toBe(null);
expect(register.errorEmailExists).toBe(null);
expect(register.errorUserExists).toBe('ERROR');
});
it('should register when password match but throw error when email already used', async () => {
const error = { response: { status: 400, data: { type: EMAIL_ALREADY_USED_TYPE } } };
axiosStub.post.rejects(error);
register.registerAccount = filledRegisterAccount;
register.confirmPassword = filledRegisterAccount.password;
register.register();
await register.$nextTick();
expect(
axiosStub.post.calledWith('api/register', { email: 'jhi@pster.net', langKey: 'it', login: 'jhi', password: 'jhipster' }),
).toBeTruthy();
await register.$nextTick();
expect(register.success).toBe(null);
expect(register.error).toBe(null);
expect(register.errorEmailExists).toBe('ERROR');
expect(register.errorUserExists).toBe(null);
});
it('should register when password match but throw error', async () => {
const error = { response: { status: 400, data: { type: 'unknown' } } };
axiosStub.post.rejects(error);
register.registerAccount = filledRegisterAccount;
register.confirmPassword = filledRegisterAccount.password;
register.register();
await register.$nextTick();
expect(
axiosStub.post.calledWith('api/register', { email: 'jhi@pster.net', langKey: 'it', login: 'jhi', password: 'jhipster' }),
).toBeTruthy();
await register.$nextTick();
expect(register.success).toBe(null);
expect(register.errorEmailExists).toBe(null);
expect(register.errorUserExists).toBe(null);
expect(register.error).toBe('ERROR');
});
});

View File

@@ -0,0 +1,98 @@
import { type Ref, computed, defineComponent, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { email, helpers, maxLength, minLength, required, sameAs } from '@vuelidate/validators';
import { useLoginModal } from '@/account/login-modal';
import RegisterService from '@/account/register/register.service';
import { EMAIL_ALREADY_USED_TYPE, LOGIN_ALREADY_USED_TYPE } from '@/constants';
const loginPattern = helpers.regex(/^[a-zA-Z0-9!$&*+=?^_`{|}~.-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*$|^[_.@A-Za-z0-9-]+$/);
export default defineComponent({
name: 'Register',
validations() {
return {
registerAccount: {
login: {
required,
minLength: minLength(1),
maxLength: maxLength(50),
pattern: loginPattern,
},
email: {
required,
minLength: minLength(5),
maxLength: maxLength(254),
email,
},
password: {
required,
minLength: minLength(4),
maxLength: maxLength(254),
},
},
confirmPassword: {
required,
minLength: minLength(4),
maxLength: maxLength(50),
sameAsPassword: sameAs(this.registerAccount.password),
},
};
},
setup() {
const { showLogin } = useLoginModal();
const registerService = inject('registerService', () => new RegisterService(), true);
const currentLanguage = inject('currentLanguage', () => computed(() => navigator.language ?? 'it'), true);
const error: Ref<string> = ref('');
const errorEmailExists: Ref<string> = ref('');
const errorUserExists: Ref<string> = ref('');
const success: Ref<boolean> = ref(false);
const confirmPassword: Ref<any> = ref(null);
const registerAccount: Ref<any> = ref({
login: undefined,
email: undefined,
password: undefined,
});
return {
showLogin,
currentLanguage,
registerService,
error,
errorEmailExists,
errorUserExists,
success,
confirmPassword,
registerAccount,
v$: useVuelidate(),
t$: useI18n().t,
};
},
methods: {
register(): void {
this.error = null;
this.errorUserExists = null;
this.errorEmailExists = null;
this.registerAccount.langKey = this.currentLanguage;
this.registerService
.processRegistration(this.registerAccount)
.then(() => {
this.success = true;
})
.catch(error => {
this.success = null;
if (error.response.status === 400 && error.response.data.type === LOGIN_ALREADY_USED_TYPE) {
this.errorUserExists = 'ERROR';
} else if (error.response.status === 400 && error.response.data.type === EMAIL_ALREADY_USED_TYPE) {
this.errorEmailExists = 'ERROR';
} else {
this.error = 'ERROR';
}
});
},
},
});

View File

@@ -0,0 +1,7 @@
import axios from 'axios';
export default class RegisterService {
processRegistration(account: any): Promise<any> {
return axios.post('api/register', account);
}
}

View File

@@ -0,0 +1,152 @@
<template>
<div>
<div class="d-flex justify-content-center">
<div class="col-md-8 toastify-container">
<h1 id="register-title" data-cy="registerTitle">{{ t$('register.title') }}</h1>
<div class="alert alert-success" role="alert" v-if="success" v-html="t$('register.messages.success')"></div>
<div class="alert alert-danger" role="alert" v-if="error" v-html="t$('register.messages.error.fail')"></div>
<div class="alert alert-danger" role="alert" v-if="errorUserExists" v-html="t$('register.messages.error.userexists')"></div>
<div class="alert alert-danger" role="alert" v-if="errorEmailExists" v-html="t$('register.messages.error.emailexists')"></div>
</div>
</div>
<div class="d-flex justify-content-center">
<div class="col-md-8">
<form id="register-form" name="registerForm" @submit.prevent="register()" v-if="!success" no-validate>
<div class="mb-3">
<label class="form-control-label" for="username">{{ t$("global.form['username.label']") }}</label>
<input
type="text"
class="form-control"
v-model="v$.registerAccount.login.$model"
id="username"
name="login"
:class="{ 'is-valid': !v$.registerAccount.login.$invalid, 'is-invalid': v$.registerAccount.login.$invalid }"
required
minlength="1"
maxlength="50"
pattern="^[a-zA-Z0-9!#$&'*+=?^_`{|}~.-]+@?[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$"
:placeholder="t$('global.form[\'username.placeholder\']')"
data-cy="username"
/>
<div v-if="v$.registerAccount.login.$anyDirty && v$.registerAccount.login.$invalid">
<small class="form-text text-danger" v-if="v$.registerAccount.login.required.$invalid">{{
t$('register.messages.validate.login.required')
}}</small>
<small class="form-text text-danger" v-if="v$.registerAccount.login.minLength.$invalid">{{
t$('register.messages.validate.login.minlength')
}}</small>
<small class="form-text text-danger" v-if="v$.registerAccount.login.maxLength.$invalid">{{
t$('register.messages.validate.login.maxlength')
}}</small>
<small class="form-text text-danger" v-if="v$.registerAccount.login.pattern.$invalid">{{
t$('register.messages.validate.login.pattern')
}}</small>
</div>
</div>
<div class="mb-3">
<label class="form-control-label" for="email">{{ t$("global.form['email.label']") }}</label>
<input
type="email"
class="form-control"
id="email"
name="email"
:class="{ 'is-valid': !v$.registerAccount.email.$invalid, 'is-invalid': v$.registerAccount.email.$invalid }"
v-model="v$.registerAccount.email.$model"
minlength="5"
maxlength="254"
email
required
:placeholder="t$('global.form[\'email.placeholder\']')"
data-cy="email"
/>
<div v-if="v$.registerAccount.email.$anyDirty && v$.registerAccount.email.$invalid">
<small class="form-text text-danger" v-if="v$.registerAccount.email.required.$invalid">{{
t$('global.messages.validate.email.required')
}}</small>
<small class="form-text text-danger" v-if="v$.registerAccount.email.email.$invalid">{{
t$('global.messages.validate.email.invalid')
}}</small>
<small class="form-text text-danger" v-if="v$.registerAccount.email.minLength.$invalid">{{
t$('global.messages.validate.email.minlength')
}}</small>
<small class="form-text text-danger" v-if="v$.registerAccount.email.maxLength.$invalid">{{
t$('global.messages.validate.email.maxlength')
}}</small>
</div>
</div>
<div class="mb-3">
<label class="form-control-label" for="firstPassword">{{ t$("global.form['newpassword.label']") }}</label>
<input
type="password"
class="form-control"
id="firstPassword"
name="password"
:class="{ 'is-valid': !v$.registerAccount.password.$invalid, 'is-invalid': v$.registerAccount.password.$invalid }"
v-model="v$.registerAccount.password.$model"
minlength="4"
maxlength="50"
required
:placeholder="t$('global.form[\'newpassword.placeholder\']')"
data-cy="firstPassword"
/>
<div v-if="v$.registerAccount.password.$anyDirty && v$.registerAccount.password.$invalid">
<small class="form-text text-danger" v-if="v$.registerAccount.password.required.$invalid">{{
t$('global.messages.validate.newpassword.required')
}}</small>
<small class="form-text text-danger" v-if="v$.registerAccount.password.minLength.$invalid">{{
t$('global.messages.validate.newpassword.minlength')
}}</small>
<small class="form-text text-danger" v-if="v$.registerAccount.password.maxLength.$invalid">{{
t$('global.messages.validate.newpassword.maxlength')
}}</small>
</div>
</div>
<div class="mb-3">
<label class="form-control-label" for="secondPassword">{{ t$("global.form['confirmpassword.label']") }}</label>
<input
type="password"
class="form-control"
id="secondPassword"
name="confirmPasswordInput"
:class="{ 'is-valid': !v$.confirmPassword.$invalid, 'is-invalid': v$.confirmPassword.$invalid }"
v-model="v$.confirmPassword.$model"
minlength="4"
maxlength="50"
required
:placeholder="t$('global.form[\'confirmpassword.placeholder\']')"
data-cy="secondPassword"
/>
<div v-if="v$.confirmPassword.$dirty && v$.confirmPassword.$invalid">
<small class="form-text text-danger" v-if="v$.confirmPassword.required.$invalid">{{
t$('global.messages.validate.confirmpassword.required')
}}</small>
<small class="form-text text-danger" v-if="v$.confirmPassword.minLength.$invalid">{{
t$('global.messages.validate.confirmpassword.minlength')
}}</small>
<small class="form-text text-danger" v-if="v$.confirmPassword.maxLength.$invalid">{{
t$('global.messages.validate.confirmpassword.maxlength')
}}</small>
<small class="form-text text-danger" v-if="v$.confirmPassword.sameAsPassword">{{
t$('global.messages.error.dontmatch')
}}</small>
</div>
</div>
<button type="submit" :disabled="v$.$invalid" class="btn btn-primary" data-cy="submit">{{ t$('register.form.button') }}</button>
</form>
<p></p>
<div class="alert alert-warning">
<span>{{ t$('global.messages.info.authenticated.prefix') }}</span>
<a class="alert-link" @click="showLogin()">{{ t$('global.messages.info.authenticated.link') }}</a
><span v-html="t$('global.messages.info.authenticated.suffix')"></span>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" src="./register.component.ts"></script>

View File

@@ -0,0 +1,58 @@
import { createTestingPinia } from '@pinia/testing';
import { shallowMount } from '@vue/test-utils';
import axios from 'axios';
import sinon from 'sinon';
import ResetPasswordFinish from './reset-password-finish.vue';
type ResetPasswordFinishComponentType = InstanceType<typeof ResetPasswordFinish>;
const axiosStub = {
get: sinon.stub(axios, 'get'),
post: sinon.stub(axios, 'post'),
};
describe('Reset Component Finish', () => {
let resetPasswordFinish: ResetPasswordFinishComponentType;
beforeEach(() => {
axiosStub.post.reset();
const wrapper = shallowMount(ResetPasswordFinish, {
global: {
plugins: [createTestingPinia()],
},
});
resetPasswordFinish = wrapper.vm;
});
it('should reset finish be a success', async () => {
// Given
axiosStub.post.resolves();
// When
await resetPasswordFinish.finishReset();
// Then
expect(resetPasswordFinish.success).toBeTruthy();
});
it('should reset request fail as an error', async () => {
// Given
axiosStub.post.rejects({
response: {
status: null,
data: {
type: null,
},
},
});
// When
await resetPasswordFinish.finishReset();
await resetPasswordFinish.$nextTick();
// Then
expect(resetPasswordFinish.success).toBeNull();
expect(resetPasswordFinish.error).toEqual('ERROR');
});
});

View File

@@ -0,0 +1,77 @@
import { type Ref, defineComponent, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { maxLength, minLength, required, sameAs } from '@vuelidate/validators';
import axios from 'axios';
import { useLoginModal } from '@/account/login-modal';
export default defineComponent({
name: 'ResetPasswordFinish',
validations() {
return {
resetAccount: {
newPassword: {
required,
minLength: minLength(4),
maxLength: maxLength(254),
},
confirmPassword: {
sameAsPassword: sameAs(this.resetAccount.newPassword),
},
},
};
},
setup() {
const { showLogin } = useLoginModal();
const doNotMatch: Ref<string> = ref(null);
const success: Ref<string> = ref(null);
const error: Ref<string> = ref(null);
const keyMissing: Ref<boolean> = ref(false);
const key: Ref<any> = ref(null);
const resetAccount: Ref<any> = ref({
newPassword: null,
confirmPassword: null,
});
return {
showLogin,
doNotMatch,
success,
error,
keyMissing,
key,
resetAccount,
v$: useVuelidate(),
t$: useI18n().t,
};
},
created(): void {
if (this.$route?.query?.key !== undefined) {
this.key = this.$route.query.key;
}
this.keyMissing = !this.key;
},
methods: {
finishReset() {
this.doNotMatch = null;
this.success = null;
this.error = null;
if (this.resetAccount.newPassword !== this.resetAccount.confirmPassword) {
this.doNotMatch = 'ERROR';
} else {
return axios
.post('api/account/reset-password/finish', { key: this.key, newPassword: this.resetAccount.newPassword })
.then(() => {
this.success = 'OK';
})
.catch(() => {
this.success = null;
this.error = 'ERROR';
});
}
},
},
});

View File

@@ -0,0 +1,85 @@
<template>
<div>
<div class="d-flex justify-content-center">
<div class="col-md-8">
<h1>{{ t$('reset.request.title') }}</h1>
<div class="alert alert-danger" v-html="t$('reset.finish.messages.keymissing')" v-if="keyMissing"></div>
<div class="alert alert-danger" v-if="error">
<p>{{ t$('reset.finish.messages.error') }}</p>
</div>
<div class="alert alert-success" v-if="success">
<span v-html="t$('reset.finish.messages.success')"></span>
<a class="alert-link" @click="showLogin()">{{ t$('global.messages.info.authenticated.link') }}</a>
</div>
<div class="alert alert-danger" v-if="doNotMatch">
<p>{{ t$('global.messages.error.dontmatch') }}</p>
</div>
<div class="alert alert-warning" v-if="!success && !keyMissing">
<p>{{ t$('reset.finish.messages.info') }}</p>
</div>
<div v-if="!keyMissing">
<form v-if="!success" name="form" @submit.prevent="finishReset()">
<div class="mb-3">
<label class="form-control-label" for="newPassword">{{ t$("global.form['newpassword.label']") }}</label>
<input
type="password"
class="form-control"
id="newPassword"
name="newPassword"
:placeholder="t$('global.form[\'newpassword.placeholder\']')"
:class="{ 'is-valid': !v$.resetAccount.newPassword.$invalid, 'is-invalid': v$.resetAccount.newPassword.$invalid }"
v-model="v$.resetAccount.newPassword.$model"
minlength="4"
maxlength="50"
required
data-cy="resetPassword"
/>
<div v-if="v$.resetAccount.newPassword.$anyDirty && v$.resetAccount.newPassword.$invalid">
<small class="form-text text-danger" v-if="v$.resetAccount.newPassword.required.$invalid">{{
t$('global.messages.validate.newpassword.required')
}}</small>
<small class="form-text text-danger" v-if="v$.resetAccount.newPassword.minLength.$invalid">{{
t$('global.messages.validate.newpassword.minlength')
}}</small>
<small class="form-text text-danger" v-if="v$.resetAccount.newPassword.maxLength.$invalid">{{
t$('global.messages.validate.newpassword.maxlength')
}}</small>
</div>
</div>
<div class="mb-3">
<label class="form-control-label" for="confirmPassword">{{ t$("global.form['confirmpassword.label']") }}</label>
<input
type="password"
class="form-control"
id="confirmPassword"
name="confirmPassword"
:class="{ 'is-valid': !v$.resetAccount.confirmPassword.$invalid, 'is-invalid': v$.resetAccount.confirmPassword.$invalid }"
:placeholder="t$('global.form[\'confirmpassword.placeholder\']')"
v-model="v$.resetAccount.confirmPassword.$model"
minlength="4"
maxlength="50"
required
data-cy="confirmResetPassword"
/>
<div v-if="v$.resetAccount.confirmPassword.$anyDirty && v$.resetAccount.confirmPassword.$invalid">
<small class="form-text text-danger" v-if="v$.resetAccount.confirmPassword.sameAsPassword.$invalid">{{
t$('global.messages.error.dontmatch')
}}</small>
</div>
</div>
<button type="submit" :disabled="v$.resetAccount.$invalid" class="btn btn-primary" data-cy="submit">
{{ t$('password.form.button') }}
</button>
</form>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" src="./reset-password-finish.component.ts"></script>

View File

@@ -0,0 +1,53 @@
import { shallowMount } from '@vue/test-utils';
import axios from 'axios';
import sinon from 'sinon';
import ResetPasswordInit from './reset-password-init.vue';
type ResetPasswordInitComponentType = InstanceType<typeof ResetPasswordInit>;
const axiosStub = {
get: sinon.stub(axios, 'get'),
post: sinon.stub(axios, 'post'),
};
describe('Reset Component Init', () => {
let resetPasswordInit: ResetPasswordInitComponentType;
beforeEach(() => {
axiosStub.post.reset();
const wrapper = shallowMount(ResetPasswordInit, {});
resetPasswordInit = wrapper.vm;
});
it('should reset request be a success', async () => {
// Given
axiosStub.post.resolves();
// When
await resetPasswordInit.requestReset();
// Then
expect(resetPasswordInit.success).toBeTruthy();
});
it('should reset request fail as an error', async () => {
// Given
axiosStub.post.rejects({
response: {
status: null,
data: {
type: null,
},
},
});
// When
await resetPasswordInit.requestReset();
await resetPasswordInit.$nextTick();
// Then
expect(resetPasswordInit.success).toBe(false);
expect(resetPasswordInit.error).toEqual('ERROR');
});
});

View File

@@ -0,0 +1,60 @@
import { type Ref, defineComponent, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { email, maxLength, minLength, required } from '@vuelidate/validators';
import axios from 'axios';
const validations = {
resetAccount: {
email: {
required,
minLength: minLength(5),
maxLength: maxLength(254),
email,
},
},
};
interface ResetAccount {
email: string | null;
}
export default defineComponent({
name: 'ResetPasswordInit',
validations,
setup() {
const error: Ref<string> = ref(null);
const success: Ref<boolean> = ref(false);
const resetAccount: Ref<ResetAccount> = ref({
email: null,
});
return {
error,
success,
resetAccount,
v$: useVuelidate(),
t$: useI18n().t,
};
},
methods: {
async requestReset(): Promise<void> {
this.error = null;
this.success = false;
await axios
.post('api/account/reset-password/init', this.resetAccount.email, {
headers: {
'content-type': 'text/plain',
},
})
.then(() => {
this.success = true;
})
.catch(() => {
this.success = false;
this.error = 'ERROR';
});
},
},
});

View File

@@ -0,0 +1,56 @@
<template>
<div>
<div class="d-flex justify-content-center">
<div class="col-md-8">
<h1>{{ t$('reset.request.title') }}</h1>
<div class="alert alert-warning" v-if="!success">
<p>{{ t$('reset.request.messages.info') }}</p>
</div>
<div class="alert alert-success" v-if="success">
<p>{{ t$('reset.request.messages.success') }}</p>
</div>
<form v-if="!success" name="form" @submit.prevent="requestReset()">
<div class="mb-3">
<label class="form-control-label" for="email">{{ t$("global.form['email.label']") }}</label>
<input
type="email"
class="form-control"
id="email"
name="email"
:placeholder="t$('global.form[\'email.placeholder\']')"
:class="{ 'is-valid': !v$.resetAccount.email.$invalid, 'is-invalid': v$.resetAccount.email.$invalid }"
v-model="v$.resetAccount.email.$model"
minlength="5"
maxlength="254"
email
required
data-cy="emailResetPassword"
/>
<div v-if="v$.resetAccount.email.$anyDirty && v$.resetAccount.email.$invalid">
<small class="form-text text-danger" v-if="v$.resetAccount.email.required.$invalid">{{
t$('global.messages.validate.email.required')
}}</small>
<small class="form-text text-danger" v-if="v$.resetAccount.email.email.$invalid">{{
t$('global.messages.validate.email.invalid')
}}</small>
<small class="form-text text-danger" v-if="v$.resetAccount.email.minLength.$invalid">{{
t$('global.messages.validate.email.minlength')
}}</small>
<small class="form-text text-danger" v-if="v$.resetAccount.email.maxLength.$invalid">{{
t$('global.messages.validate.email.maxlength')
}}</small>
</div>
</div>
<button type="submit" :disabled="v$.resetAccount.$invalid" class="btn btn-primary" data-cy="submit">
{{ t$('reset.request.form.button') }}
</button>
</form>
</div>
</div>
</div>
</template>
<script lang="ts" src="./reset-password-init.component.ts"></script>

View File

@@ -0,0 +1,80 @@
import { createTestingPinia } from '@pinia/testing';
import { shallowMount } from '@vue/test-utils';
import axios from 'axios';
import sinon from 'sinon';
import { useStore } from '@/store';
import Sessions from './sessions.vue';
type SessionsComponentType = InstanceType<typeof Sessions>;
const pinia = createTestingPinia({ stubActions: false });
const store = useStore();
const axiosStub = {
get: sinon.stub(axios, 'get'),
delete: sinon.stub(axios, 'delete'),
};
describe('Sessions Component', () => {
let sessions: SessionsComponentType;
beforeEach(() => {
axiosStub.get.reset();
axiosStub.get.resolves({ data: [] });
axiosStub.delete.reset();
store.setAuthentication({
login: 'username',
});
const wrapper = shallowMount(Sessions, {
global: {
plugins: [pinia],
},
});
sessions = wrapper.vm;
});
it('should call remote service on init', () => {
expect(axiosStub.get.callCount).toEqual(1);
});
it('should have good username', () => {
expect(sessions.username).toEqual('username');
});
it('should invalidate a session', async () => {
// Given
axiosStub.get.reset();
axiosStub.get.resolves({ data: [] });
axiosStub.delete.resolves();
// When
await sessions.invalidate('session');
await sessions.$nextTick();
// Then
expect(sessions.error).toBeNull();
expect(sessions.success).toEqual('OK');
expect(axiosStub.get.callCount).toEqual(1);
});
it('should fail to invalidate session', async () => {
// Given
axiosStub.get.reset();
axiosStub.get.resolves({ data: [] });
axiosStub.delete.rejects();
// When
await sessions.invalidate('session');
await sessions.$nextTick();
// Then
expect(sessions.success).toBeNull();
expect(sessions.error).toEqual('ERROR');
expect(axiosStub.get.callCount).toEqual(0);
});
});

View File

@@ -0,0 +1,53 @@
import { type Ref, computed, defineComponent, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import axios from 'axios';
import { useStore } from '@/store';
export default defineComponent({
name: 'Sessions',
setup() {
const store = useStore();
const success: Ref<string> = ref(null);
const error: Ref<string> = ref(null);
const sessions: Ref<any[]> = ref([]);
const authenticated = computed(() => store.authenticated);
const username = computed(() => store.account?.login ?? '');
return {
success,
error,
sessions,
authenticated,
username,
t$: useI18n().t,
};
},
created(): void {
this.retrieveSessions();
},
methods: {
retrieveSessions() {
return axios.get('api/account/sessions').then(response => {
this.error = null;
this.sessions = response.data;
});
},
invalidate(session) {
return axios
.delete(`api/account/sessions/${session}`)
.then(() => {
this.error = null;
this.success = 'OK';
this.retrieveSessions();
})
.catch(() => {
this.success = null;
this.error = 'ERROR';
});
},
},
});

View File

@@ -0,0 +1,38 @@
<template>
<div>
<h2 v-if="authenticated">
<span
>{{ t$('sessions.title') }}<strong>{{ username }}</strong
>]</span
>
</h2>
<div class="alert alert-success" v-if="success" v-html="t$('sessions.messages.success')"></div>
<div class="alert alert-danger" v-if="error" v-html="t$('sessions.messages.error')"></div>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>{{ t$('sessions.table.ipaddress') }}</th>
<th>{{ t$('sessions.table.useragent') }}</th>
<th>{{ t$('sessions.table.date') }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="session in sessions" :key="session.ipAddress">
<td>{{ session.ipAddress }}</td>
<td>{{ session.userAgent }}</td>
<td>{{ session.tokenDate }}</td>
<td>
<button type="submit" class="btn btn-primary" @click="invalidate(session.series)">{{ t$('sessions.table.button') }}</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script lang="ts" src="./sessions.component.ts"></script>

View File

@@ -0,0 +1,112 @@
import { createTestingPinia } from '@pinia/testing';
import { shallowMount } from '@vue/test-utils';
import axios from 'axios';
import sinon from 'sinon';
import { EMAIL_ALREADY_USED_TYPE } from '@/constants';
import { useStore } from '@/store';
import Settings from './settings.vue';
type SettingsComponentType = InstanceType<typeof Settings>;
const pinia = createTestingPinia({ stubActions: false });
const store = useStore();
const axiosStub = {
get: sinon.stub(axios, 'get'),
post: sinon.stub(axios, 'post'),
};
describe('Settings Component', () => {
let settings: SettingsComponentType;
const account = {
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@jhipster.org',
};
beforeEach(() => {
axiosStub.get.resolves({});
axiosStub.post.reset();
store.setAuthentication(account);
const wrapper = shallowMount(Settings, {
global: {
plugins: [pinia],
},
});
settings = wrapper.vm;
});
it('should send the current identity upon save', async () => {
// GIVEN
axiosStub.post.resolves({});
// WHEN
await settings.save();
await settings.$nextTick();
// THEN
expect(axiosStub.post.calledWith('api/account', account)).toBeTruthy();
});
it('should notify of success upon successful save', async () => {
// GIVEN
axiosStub.post.resolves(account);
// WHEN
await settings.save();
await settings.$nextTick();
// THEN
expect(settings.error).toBeNull();
expect(settings.success).toBe('OK');
});
it('should notify of error upon failed save', async () => {
// GIVEN
const error = { response: { status: 417 } };
axiosStub.post.rejects(error);
// WHEN
await settings.save();
await settings.$nextTick();
// THEN
expect(settings.error).toEqual('ERROR');
expect(settings.errorEmailExists).toBeNull();
expect(settings.success).toBeNull();
});
it('should notify of error upon error 400', async () => {
// GIVEN
const error = { response: { status: 400, data: {} } };
axiosStub.post.rejects(error);
// WHEN
await settings.save();
await settings.$nextTick();
// THEN
expect(settings.error).toEqual('ERROR');
expect(settings.errorEmailExists).toBeNull();
expect(settings.success).toBeNull();
});
it('should notify of error upon email already used', async () => {
// GIVEN
const error = { response: { status: 400, data: { type: EMAIL_ALREADY_USED_TYPE } } };
axiosStub.post.rejects(error);
// WHEN
await settings.save();
await settings.$nextTick();
// THEN
expect(settings.errorEmailExists).toEqual('ERROR');
expect(settings.error).toBeNull();
expect(settings.success).toBeNull();
});
});

View File

@@ -0,0 +1,78 @@
import { type ComputedRef, type Ref, computed, defineComponent, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { email, maxLength, minLength, required } from '@vuelidate/validators';
import axios from 'axios';
import { EMAIL_ALREADY_USED_TYPE } from '@/constants';
import languages from '@/shared/config/languages';
import { useStore } from '@/store';
const validations = {
settingsAccount: {
firstName: {
required,
minLength: minLength(1),
maxLength: maxLength(50),
},
lastName: {
required,
minLength: minLength(1),
maxLength: maxLength(50),
},
email: {
required,
email,
minLength: minLength(5),
maxLength: maxLength(254),
},
},
};
export default defineComponent({
name: 'Settings',
validations,
setup() {
const store = useStore();
const success: Ref<string> = ref(null);
const error: Ref<string> = ref(null);
const errorEmailExists: Ref<string> = ref(null);
const settingsAccount = computed(() => store.account);
const username = inject<ComputedRef<string>>('currentUsername', () => computed(() => store.account?.login), true);
return {
success,
error,
errorEmailExists,
settingsAccount,
username,
v$: useVuelidate(),
languages: languages(),
t$: useI18n().t,
};
},
methods: {
save() {
this.error = null;
this.errorEmailExists = null;
return axios
.post('api/account', this.settingsAccount)
.then(() => {
this.error = null;
this.success = 'OK';
this.errorEmailExists = null;
})
.catch(ex => {
this.success = null;
this.error = 'ERROR';
if (ex.response.status === 400 && ex.response.data.type === EMAIL_ALREADY_USED_TYPE) {
this.errorEmailExists = 'ERROR';
this.error = null;
}
});
},
},
});

View File

@@ -0,0 +1,114 @@
<template>
<div>
<div class="d-flex justify-content-center">
<div class="col-md-8 toastify-container">
<h2 v-if="username" id="settings-title">
<span v-html="t$('settings.title', { username })"></span>
</h2>
<div class="alert alert-success" role="alert" v-if="success" v-html="t$('settings.messages.success')"></div>
<div class="alert alert-danger" role="alert" v-if="errorEmailExists" v-html="t$('register.messages.error.emailexists')"></div>
<form name="form" id="settings-form" @submit.prevent="save()" v-if="settingsAccount" novalidate>
<div class="mb-3">
<label class="form-control-label" for="firstName">{{ t$('settings.form.firstname') }}</label>
<input
type="text"
class="form-control"
id="firstName"
name="firstName"
:placeholder="t$('settings.form[\'firstname.placeholder\']')"
:class="{ 'is-valid': !v$.settingsAccount.firstName.$invalid, 'is-invalid': v$.settingsAccount.firstName.$invalid }"
v-model="v$.settingsAccount.firstName.$model"
minlength="1"
maxlength="50"
required
data-cy="firstname"
/>
<div v-if="v$.settingsAccount.firstName.$anyDirty && v$.settingsAccount.firstName.$invalid">
<small class="form-text text-danger" v-if="v$.settingsAccount.firstName.required.$invalid">{{
t$('settings.messages.validate.firstname.required')
}}</small>
<small class="form-text text-danger" v-if="v$.settingsAccount.firstName.minLength.$invalid">{{
t$('settings.messages.validate.firstname.minlength')
}}</small>
<small class="form-text text-danger" v-if="v$.settingsAccount.firstName.maxLength.$invalid">{{
t$('settings.messages.validate.firstname.maxlength')
}}</small>
</div>
</div>
<div class="mb-3">
<label class="form-control-label" for="lastName">{{ t$('settings.form.lastname') }}</label>
<input
type="text"
class="form-control"
id="lastName"
name="lastName"
:placeholder="t$('settings.form[\'lastname.placeholder\']')"
:class="{ 'is-valid': !v$.settingsAccount.lastName.$invalid, 'is-invalid': v$.settingsAccount.lastName.$invalid }"
v-model="v$.settingsAccount.lastName.$model"
minlength="1"
maxlength="50"
required
data-cy="lastname"
/>
<div v-if="v$.settingsAccount.lastName.$anyDirty && v$.settingsAccount.lastName.$invalid">
<small class="form-text text-danger" v-if="v$.settingsAccount.lastName.required.$invalid">{{
t$('settings.messages.validate.lastname.required')
}}</small>
<small class="form-text text-danger" v-if="v$.settingsAccount.lastName.minLength.$invalid">{{
t$('settings.messages.validate.lastname.minlength')
}}</small>
<small class="form-text text-danger" v-if="v$.settingsAccount.lastName.maxLength.$invalid">{{
t$('settings.messages.validate.lastname.maxlength')
}}</small>
</div>
</div>
<div class="mb-3">
<label class="form-control-label" for="email">{{ t$("global.form['email.label']") }}</label>
<input
type="email"
class="form-control"
id="email"
name="email"
:placeholder="t$('global.form[\'email.placeholder\']')"
:class="{ 'is-valid': !v$.settingsAccount.email.$invalid, 'is-invalid': v$.settingsAccount.email.$invalid }"
v-model="v$.settingsAccount.email.$model"
minlength="5"
maxlength="254"
email
required
data-cy="email"
/>
<div v-if="v$.settingsAccount.email.$anyDirty && v$.settingsAccount.email.$invalid">
<small class="form-text text-danger" v-if="v$.settingsAccount.email.required.$invalid">{{
t$('global.messages.validate.email.required')
}}</small>
<small class="form-text text-danger" v-if="v$.settingsAccount.email.email.$invalid">{{
t$('global.messages.validate.email.invalid')
}}</small>
<small class="form-text text-danger" v-if="v$.settingsAccount.email.minLength.$invalid">{{
t$('global.messages.validate.email.minlength')
}}</small>
<small class="form-text text-danger" v-if="v$.settingsAccount.email.maxLength.$invalid">{{
t$('global.messages.validate.email.maxlength')
}}</small>
</div>
</div>
<div class="mb-3" v-if="languages && Object.keys(languages).length > 1">
<label for="langKey">{{ t$('settings.form.language') }}</label>
<select class="form-control" id="langKey" name="langKey" v-model="settingsAccount.langKey" data-cy="langKey">
<option v-for="(language, key) in languages" :value="key" :key="`lang-${key}`">{{ language.name }}</option>
</select>
</div>
<button type="submit" :disabled="v$.settingsAccount.$invalid" class="btn btn-primary" data-cy="submit">
{{ t$('settings.form.button') }}
</button>
</form>
</div>
</div>
</div>
</template>
<script lang="ts" src="./settings.component.ts"></script>

View File

@@ -0,0 +1,76 @@
import { shallowMount } from '@vue/test-utils';
import axios from 'axios';
import sinon from 'sinon';
import Configuration from './configuration.vue';
type ConfigurationComponentType = InstanceType<typeof Configuration>;
const axiosStub = {
get: sinon.stub(axios, 'get'),
};
describe('Configuration Component', () => {
let configuration: ConfigurationComponentType;
beforeEach(() => {
axiosStub.get.reset();
axiosStub.get.resolves({
data: { contexts: [{ beans: [{ prefix: 'A' }, { prefix: 'B' }] }], propertySources: [{ properties: { key1: { value: 'value' } } }] },
});
const wrapper = shallowMount(Configuration, {
global: {
stubs: {
'jhi-sort-indicator': true,
},
},
});
configuration = wrapper.vm;
});
describe('OnRouteEnter', () => {
it('should set all default values correctly', () => {
expect(configuration.configKeys).toEqual([]);
expect(configuration.filtered).toBe('');
expect(configuration.orderProp).toBe('prefix');
expect(configuration.reverse).toBe(false);
});
it('Should call load all on init', async () => {
// WHEN
configuration.init();
await configuration.$nextTick();
// THEN
expect(axiosStub.get.calledWith('management/env')).toBeTruthy();
expect(axiosStub.get.calledWith('management/configprops')).toBeTruthy();
});
});
describe('keys method', () => {
it('should return the keys of an Object', () => {
// GIVEN
const data = {
key1: 'test',
key2: 'test2',
};
// THEN
expect(configuration.keys(data)).toEqual(['key1', 'key2']);
expect(configuration.keys(undefined)).toEqual([]);
});
});
describe('changeOrder function', () => {
it('should change order', () => {
// GIVEN
const rev = configuration.reverse;
// WHEN
configuration.changeOrder('prefix');
// THEN
expect(configuration.orderProp).toBe('prefix');
expect(configuration.reverse).toBe(!rev);
});
});
});

View File

@@ -0,0 +1,67 @@
import { type Ref, computed, defineComponent, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { orderAndFilterBy } from '@/shared/computables';
import ConfigurationService from './configuration.service';
export default defineComponent({
name: 'JhiConfiguration',
setup() {
const configurationService = inject('configurationService', () => new ConfigurationService(), true);
const orderProp = ref('prefix');
const reverse = ref(false);
const allConfiguration: Ref<any> = ref({});
const configuration: Ref<any[]> = ref([]);
const configKeys: Ref<any[]> = ref([]);
const filtered = ref('');
const filteredConfiguration = computed(() =>
orderAndFilterBy(configuration.value, {
filterByTerm: filtered.value,
orderByProp: orderProp.value,
reverse: reverse.value,
}),
);
return {
configurationService,
orderProp,
reverse,
allConfiguration,
configuration,
configKeys,
filtered,
filteredConfiguration,
t$: useI18n().t,
};
},
mounted() {
this.init();
},
methods: {
init(): void {
this.configurationService.loadConfiguration().then(res => {
this.configuration = res;
for (const config of this.configuration) {
if (config.properties !== undefined) {
this.configKeys.push(Object.keys(config.properties));
}
}
});
this.configurationService.loadEnvConfiguration().then(res => {
this.allConfiguration = res;
});
},
changeOrder(prop: string): void {
this.orderProp = prop;
this.reverse = !this.reverse;
},
keys(dict: any): string[] {
return dict === undefined ? [] : Object.keys(dict);
},
},
});

View File

@@ -0,0 +1,55 @@
import axios from 'axios';
export default class ConfigurationService {
async loadConfiguration(): Promise<any> {
const res = await axios.get('management/configprops');
const properties = [];
const propertiesObject = this.getConfigPropertiesObjects(res.data);
for (const key in propertiesObject) {
if (Object.hasOwn(propertiesObject, key)) {
properties.push(propertiesObject[key]);
}
}
properties.sort((propertyA, propertyB) => {
const comparePrefix = propertyA.prefix < propertyB.prefix ? -1 : 1;
return propertyA.prefix === propertyB.prefix ? 0 : comparePrefix;
});
return properties;
}
async loadEnvConfiguration(): Promise<any> {
const res = await axios.get<any>('management/env');
const properties = {};
const propertySources = res.data.propertySources;
for (const propertyObject of propertySources) {
const name = propertyObject.name;
const detailProperties = propertyObject.properties;
const vals = [];
for (const keyDetail in detailProperties) {
if (Object.hasOwn(detailProperties, keyDetail)) {
vals.push({ key: keyDetail, val: detailProperties[keyDetail].value });
}
}
properties[name] = vals;
}
return properties;
}
private getConfigPropertiesObjects(res): any {
// This code is for Spring Boot 2
if (res.contexts !== undefined) {
for (const key in res.contexts) {
// If the key is not bootstrap, it will be the ApplicationContext Id
// For default app, it is baseName
// For microservice, it is baseName-1
if (!key.startsWith('bootstrap')) {
return res.contexts[key].beans;
}
}
}
// by default, use the default ApplicationContext Id
return res.contexts.smartbooking.beans;
}
}

View File

@@ -0,0 +1,62 @@
<template>
<div>
<h2 id="configuration-page-heading" data-cy="configurationPageHeading">{{ t$('configuration.title') }}</h2>
<div v-if="allConfiguration && configuration">
<span>{{ t$('configuration.filter') }}</span> <input type="text" v-model="filtered" class="form-control" />
<h3>Spring configuration</h3>
<table class="table table-striped table-bordered table-responsive d-table" aria-describedby="Configuration">
<thead>
<tr>
<th class="w-40" @click="changeOrder('prefix')" scope="col">
<span>{{ t$('configuration.table.prefix') }}</span>
<jhi-sort-indicator :current-order="orderProp" :reverse="reverse" :field-name="'prefix'"></jhi-sort-indicator>
</th>
<th class="w-60" @click="changeOrder('properties')" scope="col">
<span>{{ t$('configuration.table.properties') }}</span>
<jhi-sort-indicator :current-order="orderProp" :reverse="reverse" :field-name="'properties'"></jhi-sort-indicator>
</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in filteredConfiguration" :key="entry.prefix">
<td>
<span>{{ entry.prefix }}</span>
</td>
<td>
<div class="row" v-for="key in keys(entry.properties)" :key="key">
<div class="col-md-4">{{ key }}</div>
<div class="col-md-8">
<span class="float-end bg-secondary break">{{ entry.properties[key] }}</span>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<div v-for="key in keys(allConfiguration)" :key="key">
<h4>
<span>{{ key }}</span>
</h4>
<table class="table table-sm table-striped table-bordered table-responsive d-table" aria-describedby="Properties">
<thead>
<tr>
<th class="w-40" scope="col">Property</th>
<th class="w-60" scope="col">Value</th>
</tr>
</thead>
<tbody>
<tr v-for="item of allConfiguration[key]" :key="item.key">
<td class="break">{{ item.key }}</td>
<td class="break">
<span class="float-end bg-secondary break">{{ item.val }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script lang="ts" src="./configuration.component.ts"></script>

View File

@@ -0,0 +1,5 @@
import { defineComponent } from 'vue';
export default defineComponent({
name: 'JhiDocs',
});

View File

@@ -0,0 +1,14 @@
<template>
<iframe
src="/swagger-ui/index.html"
width="100%"
height="900"
seamless
target="_top"
title="Swagger UI"
class="border-0"
data-cy="swagger-frame"
></iframe>
</template>
<script lang="ts" src="./docs.component.ts"></script>

View File

@@ -0,0 +1,90 @@
import { vitest } from 'vitest';
import { shallowMount } from '@vue/test-utils';
import HealthModal from './health-modal.vue';
type HealthModalComponentType = InstanceType<typeof HealthModal>;
const healthService = { getBaseName: vitest.fn(), getSubSystemName: vitest.fn() };
describe('Health Modal Component', () => {
let healthModal: HealthModalComponentType;
beforeEach(() => {
const wrapper = shallowMount(HealthModal, {
propsData: {
currentHealth: {},
},
global: {
stubs: {
'font-awesome-icon': true,
},
provide: {
healthService,
},
},
});
healthModal = wrapper.vm;
});
describe('baseName and subSystemName', () => {
it('should use healthService', () => {
healthModal.baseName('base');
expect(healthService.getBaseName).toHaveBeenCalled();
});
it('should use healthService', () => {
healthModal.subSystemName('base');
expect(healthService.getSubSystemName).toHaveBeenCalled();
});
});
describe('readableValue should transform data', () => {
it('to string when is an object', () => {
const result = healthModal.readableValue({ data: 1000 });
expect(result).toBe('{"data":1000}');
});
it('to string when is a string', () => {
const result = healthModal.readableValue('data');
expect(result).toBe('data');
});
});
});
describe('Health Modal Component for diskSpace', () => {
let healthModal: HealthModalComponentType;
beforeEach(() => {
const wrapper = shallowMount(HealthModal, {
propsData: {
currentHealth: { name: 'diskSpace' },
},
global: {
provide: {
healthService,
},
},
});
healthModal = wrapper.vm;
});
describe('readableValue should transform data', () => {
it('to GB when needed', () => {
const result = healthModal.readableValue(2147483648);
expect(result).toBe('2.00 GB');
});
it('to MB when needed', () => {
const result = healthModal.readableValue(214748);
expect(result).toBe('0.20 MB');
});
});
});

View File

@@ -0,0 +1,46 @@
import { defineComponent, inject } from 'vue';
import { useI18n } from 'vue-i18n';
import HealthService from './health.service';
export default defineComponent({
name: 'JhiHealthModal',
props: {
currentHealth: {
type: Object,
default: () => ({}),
},
},
setup() {
const healthService = inject('healthService', () => new HealthService(), true);
return {
healthService,
t$: useI18n().t,
};
},
methods: {
baseName(name: string): any {
return this.healthService.getBaseName(name);
},
subSystemName(name: string): any {
return this.healthService.getSubSystemName(name);
},
readableValue(value: any): string {
if (this.currentHealth.name === 'diskSpace') {
// Should display storage space in a human readable unit
const val = value / 1073741824;
if (val > 1) {
// Value
return `${val.toFixed(2)} GB`;
}
return `${(value / 1048576).toFixed(2)} MB`;
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
return value.toString();
},
},
});

View File

@@ -0,0 +1,29 @@
<template>
<div class="modal-body pad">
<div v-if="currentHealth?.details">
<h5>{{ t$('health.details.properties') }}</h5>
<div class="table-responsive">
<table class="table table-striped" aria-describedby="Health">
<thead>
<tr>
<th class="text-start" scope="col">{{ t$('health.details.name') }}</th>
<th class="text-start" scope="col">{{ t$('health.details.value') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in currentHealth.details.details" :key="index">
<td class="text-start">{{ index }}</td>
<td class="text-start">{{ readableValue(item) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="currentHealth?.error">
<h4>{{ t$('health.details.error') }}</h4>
<pre>{{ currentHealth.error }}</pre>
</div>
</div>
</template>
<script lang="ts" src="./health-modal.component.ts"></script>

View File

@@ -0,0 +1,92 @@
import { shallowMount } from '@vue/test-utils';
import axios from 'axios';
import sinon from 'sinon';
import HealthService from './health.service';
import Health from './health.vue';
type HealthComponentType = InstanceType<typeof Health>;
const axiosStub = {
get: sinon.stub(axios, 'get'),
};
describe('Health Component', () => {
let health: HealthComponentType;
beforeEach(() => {
axiosStub.get.resolves({});
const wrapper = shallowMount(Health, {
global: {
stubs: {
bModal: true,
'font-awesome-icon': true,
'health-modal': true,
},
directives: {
'b-modal': {},
},
provide: {
healthService: new HealthService(),
},
},
});
health = wrapper.vm;
});
describe('baseName and subSystemName', () => {
it('should return the basename when it has no sub system', () => {
expect(health.baseName('base')).toBe('base');
});
it('should return the basename when it has sub systems', () => {
expect(health.baseName('base.subsystem.system')).toBe('base');
});
it('should return the sub system name', () => {
expect(health.subSystemName('subsystem')).toBe('');
});
it('should return the subsystem when it has multiple keys', () => {
expect(health.subSystemName('subsystem.subsystem.system')).toBe(' - subsystem.system');
});
});
describe('getBadgeClass', () => {
it('should get badge class', () => {
const upBadgeClass = health.getBadgeClass('UP');
const downBadgeClass = health.getBadgeClass('DOWN');
expect(upBadgeClass).toEqual('bg-success');
expect(downBadgeClass).toEqual('bg-danger');
});
});
describe('refresh', () => {
it('should call refresh on init', async () => {
// GIVEN
axiosStub.get.resolves({});
// WHEN
health.refresh();
await health.$nextTick();
// THEN
expect(axiosStub.get.calledWith('management/health')).toBeTruthy();
await health.$nextTick();
expect(health.updatingHealth).toEqual(false);
});
it('should handle a 503 on refreshing health data', async () => {
// GIVEN
axiosStub.get.rejects({});
// WHEN
health.refresh();
await health.$nextTick();
// THEN
expect(axiosStub.get.calledWith('management/health')).toBeTruthy();
await health.$nextTick();
expect(health.updatingHealth).toEqual(false);
});
});
});

View File

@@ -0,0 +1,63 @@
import { type Ref, defineComponent, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import JhiHealthModal from './health-modal.vue';
import HealthService from './health.service';
export default defineComponent({
name: 'JhiHealth',
components: {
'health-modal': JhiHealthModal,
},
setup() {
const healthService = inject('healthService', () => new HealthService(), true);
const healthData: Ref<any> = ref(null);
const currentHealth: Ref<any> = ref(null);
const updatingHealth = ref(false);
return {
healthService,
healthData,
currentHealth,
updatingHealth,
t$: useI18n().t,
};
},
mounted(): void {
this.refresh();
},
methods: {
baseName(name: any): any {
return this.healthService.getBaseName(name);
},
getBadgeClass(statusState: any): string {
if (statusState === 'UP') {
return 'bg-success';
}
return 'bg-danger';
},
refresh(): void {
this.updatingHealth = true;
this.healthService
.checkHealth()
.then(res => {
this.healthData = this.healthService.transformHealthData(res.data);
this.updatingHealth = false;
})
.catch(error => {
if (error.status === 503) {
this.healthData = this.healthService.transformHealthData(error.error);
}
this.updatingHealth = false;
});
},
showHealth(health: any): void {
this.currentHealth = health;
(<any>this.$refs.healthModal).show();
},
subSystemName(name: string): string {
return this.healthService.getSubSystemName(name);
},
},
});

View File

@@ -0,0 +1,244 @@
import HealthService from './health.service';
describe('Health Service', () => {
let healthService: HealthService;
beforeEach(() => {
healthService = new HealthService();
});
describe('transformHealthData', () => {
it('should flatten empty health data', () => {
const data = {};
const expected = [];
expect(healthService.transformHealthData(data)).toEqual(expected);
});
it('should flatten health data with no subsystems', () => {
const data = {
components: {
status: 'UP',
db: {
status: 'UP',
database: 'H2',
hello: '1',
},
mail: {
status: 'UP',
error: 'mail.a.b.c',
},
},
};
const expected = [
{
name: 'db',
status: 'UP',
details: {
database: 'H2',
hello: '1',
},
},
{
name: 'mail',
error: 'mail.a.b.c',
status: 'UP',
},
];
expect(healthService.transformHealthData(data)).toEqual(expected);
});
it('should flatten health data with subsystems at level 1, main system has no additional information', () => {
const data = {
components: {
status: 'UP',
db: {
status: 'UP',
database: 'H2',
hello: '1',
},
mail: {
status: 'UP',
error: 'mail.a.b.c',
},
system: {
status: 'DOWN',
subsystem1: {
status: 'UP',
property1: 'system.subsystem1.property1',
},
subsystem2: {
status: 'DOWN',
error: 'system.subsystem1.error',
property2: 'system.subsystem2.property2',
},
},
},
};
const expected = [
{
name: 'db',
status: 'UP',
details: {
database: 'H2',
hello: '1',
},
},
{
name: 'mail',
error: 'mail.a.b.c',
status: 'UP',
},
{
name: 'system.subsystem1',
status: 'UP',
details: {
property1: 'system.subsystem1.property1',
},
},
{
name: 'system.subsystem2',
error: 'system.subsystem1.error',
status: 'DOWN',
details: {
property2: 'system.subsystem2.property2',
},
},
];
expect(healthService.transformHealthData(data)).toEqual(expected);
});
it('should flatten health data with subsystems at level 1, main system has additional information', () => {
const data = {
components: {
status: 'UP',
db: {
status: 'UP',
database: 'H2',
hello: '1',
},
mail: {
status: 'UP',
error: 'mail.a.b.c',
},
system: {
status: 'DOWN',
property1: 'system.property1',
subsystem1: {
status: 'UP',
property1: 'system.subsystem1.property1',
},
subsystem2: {
status: 'DOWN',
error: 'system.subsystem1.error',
property2: 'system.subsystem2.property2',
},
},
},
};
const expected = [
{
name: 'db',
status: 'UP',
details: {
database: 'H2',
hello: '1',
},
},
{
name: 'mail',
error: 'mail.a.b.c',
status: 'UP',
},
{
name: 'system',
status: 'DOWN',
details: {
property1: 'system.property1',
},
},
{
name: 'system.subsystem1',
status: 'UP',
details: {
property1: 'system.subsystem1.property1',
},
},
{
name: 'system.subsystem2',
error: 'system.subsystem1.error',
status: 'DOWN',
details: {
property2: 'system.subsystem2.property2',
},
},
];
expect(healthService.transformHealthData(data)).toEqual(expected);
});
it('should flatten health data with subsystems at level 1, main system has additional error', () => {
const data = {
components: {
status: 'UP',
db: {
status: 'UP',
database: 'H2',
hello: '1',
},
mail: {
status: 'UP',
error: 'mail.a.b.c',
},
system: {
status: 'DOWN',
error: 'show me',
subsystem1: {
status: 'UP',
property1: 'system.subsystem1.property1',
},
subsystem2: {
status: 'DOWN',
error: 'system.subsystem1.error',
property2: 'system.subsystem2.property2',
},
},
},
};
const expected = [
{
name: 'db',
status: 'UP',
details: {
database: 'H2',
hello: '1',
},
},
{
name: 'mail',
error: 'mail.a.b.c',
status: 'UP',
},
{
name: 'system',
error: 'show me',
status: 'DOWN',
},
{
name: 'system.subsystem1',
status: 'UP',
details: {
property1: 'system.subsystem1.property1',
},
},
{
name: 'system.subsystem2',
error: 'system.subsystem1.error',
status: 'DOWN',
details: {
property2: 'system.subsystem2.property2',
},
},
];
expect(healthService.transformHealthData(data)).toEqual(expected);
});
});
});

View File

@@ -0,0 +1,126 @@
import axios, { type AxiosPromise } from 'axios';
export default class HealthService {
separator: string;
constructor() {
this.separator = '.';
}
checkHealth(): AxiosPromise<any> {
return axios.get('management/health');
}
transformHealthData(data: any): any {
const response = [];
this.flattenHealthData(response, null, data.components);
return response;
}
getBaseName(name: string): string {
if (name) {
const split = name.split('.');
return split[0];
}
}
getSubSystemName(name: string): string {
if (name) {
const split = name.split('.');
split.splice(0, 1);
const remainder = split.join('.');
return remainder ? ` - ${remainder}` : '';
}
}
addHealthObject(result: any, isLeaf: boolean, healthObject: any, name: string) {
const healthData = {
name,
details: undefined,
error: undefined,
};
const details = {};
let hasDetails = false;
for (const key in healthObject) {
if (Object.hasOwn(healthObject, key)) {
const value = healthObject[key];
if (key === 'status' || key === 'error') {
healthData[key] = value;
} else {
if (!this.isHealthObject(value)) {
details[key] = value;
hasDetails = true;
}
}
}
}
// Add the details
if (hasDetails) {
healthData.details = details;
}
// Only add nodes if they provide additional information
if (isLeaf || hasDetails || healthData.error) {
result.push(healthData);
}
return healthData;
}
flattenHealthData(result: any, path: any, data: any): any {
for (const key in data) {
if (Object.hasOwn(data, key)) {
const value = data[key];
if (this.isHealthObject(value)) {
if (this.hasSubSystem(value)) {
this.addHealthObject(result, false, value, this.getModuleName(path, key));
this.flattenHealthData(result, this.getModuleName(path, key), value);
} else {
this.addHealthObject(result, true, value, this.getModuleName(path, key));
}
}
}
}
return result;
}
getModuleName(path: any, name: string) {
if (path && name) {
return path + this.separator + name;
} else if (path) {
return path;
} else if (name) {
return name;
}
return '';
}
hasSubSystem(healthObject: any): any {
let result = false;
for (const key in healthObject) {
if (Object.hasOwn(healthObject, key)) {
const value = healthObject[key];
if (value?.status) {
result = true;
}
}
}
return result;
}
isHealthObject(healthObject: any): any {
let result = false;
for (const key in healthObject) {
if (Object.hasOwn(healthObject, key)) {
if (key === 'status') {
result = true;
}
}
}
return result;
}
}

View File

@@ -0,0 +1,45 @@
<template>
<div>
<h2>
<span id="health-page-heading" data-cy="healthPageHeading">{{ t$('health.title') }}</span>
<button class="btn btn-primary float-end" @click="refresh()" :disabled="updatingHealth">
<font-awesome-icon icon="sync"></font-awesome-icon> <span>{{ t$("health['refresh.button']") }}</span>
</button>
</h2>
<div class="table-responsive">
<table id="healthCheck" class="table table-striped" aria-describedby="Health check">
<thead>
<tr>
<th scope="col">{{ t$('health.table.service') }}</th>
<th class="text-center" scope="col">{{ t$('health.table.status') }}</th>
<th class="text-center" scope="col">{{ t$('health.details.details') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="health of healthData" :key="health.name">
<td><span />{{ t$('health.indicator.' + baseName(health.name)) }} {{ subSystemName(health.name) }}</td>
<td class="text-center">
<span class="badge" :class="getBadgeClass(health.status)">{{ t$('health.status.' + health.status) }}</span>
</td>
<td class="text-center">
<a class="hand" @click="showHealth(health)" v-if="health.details || health.error">
<font-awesome-icon icon="eye"></font-awesome-icon>
</a>
</td>
</tr>
</tbody>
</table>
</div>
<b-modal ref="healthModal" ok-only>
<template #title>
<h4 v-if="currentHealth" class="modal-title" id="showHealthLabel">
<span>{{ t$('health.indicator.' + baseName(currentHealth.name)) }}</span>
{{ subSystemName(currentHealth.name) }}
</h4>
</template>
<health-modal :current-health="currentHealth"></health-modal>
</b-modal>
</div>
</template>
<script lang="ts" src="./health.component.ts"></script>

View File

@@ -0,0 +1,72 @@
import { shallowMount } from '@vue/test-utils';
import axios from 'axios';
import sinon from 'sinon';
import Logs from './logs.vue';
type LogsComponentType = InstanceType<typeof Logs>;
const axiosStub = {
get: sinon.stub(axios, 'get'),
post: sinon.stub(axios, 'post'),
};
describe('Logs Component', () => {
let logs: LogsComponentType;
beforeEach(() => {
axiosStub.get.resolves({});
const wrapper = shallowMount(Logs, {
global: {
stubs: {
'jhi-sort-indicator': true,
BButton: true,
BButtonGroup: true,
},
},
});
logs = wrapper.vm;
});
describe('OnInit', () => {
it('should set all default values correctly', () => {
expect(logs.filtered).toBe('');
expect(logs.orderProp).toBe('name');
expect(logs.reverse).toBe(false);
});
it('Should call load all on init', async () => {
// WHEN
logs.init();
await logs.$nextTick();
// THEN
expect(axiosStub.get.calledWith('management/loggers')).toBeTruthy();
});
});
describe('change log level', () => {
it('should change log level correctly', async () => {
axiosStub.post.resolves({});
// WHEN
logs.updateLevel('main', 'ERROR');
await logs.$nextTick();
// THEN
expect(axiosStub.post.calledWith('management/loggers/main', { configuredLevel: 'ERROR' })).toBeTruthy();
expect(axiosStub.get.calledWith('management/loggers')).toBeTruthy();
});
});
describe('change order', () => {
it('should change order and invert reverse', () => {
// WHEN
logs.changeOrder('dummy-order');
// THEN
expect(logs.orderProp).toEqual('dummy-order');
expect(logs.reverse).toBe(true);
});
});
});

View File

@@ -0,0 +1,63 @@
import { type Ref, computed, defineComponent, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { orderAndFilterBy } from '@/shared/computables';
import LogsService from './logs.service';
export default defineComponent({
name: 'JhiLogs',
setup() {
const logsService = inject('logsService', () => new LogsService(), true);
const loggers: Ref<any[]> = ref([]);
const filtered = ref('');
const orderProp = ref('name');
const reverse = ref(false);
const filteredLoggers = computed(() =>
orderAndFilterBy(loggers.value, {
filterByTerm: filtered.value,
orderByProp: orderProp.value,
reverse: reverse.value,
}),
);
return {
logsService,
loggers,
filtered,
orderProp,
reverse,
filteredLoggers,
t$: useI18n().t,
};
},
mounted() {
this.init();
},
methods: {
init(): void {
this.logsService.findAll().then(response => {
this.extractLoggers(response);
});
},
updateLevel(name: string, level: string): void {
this.logsService.changeLevel(name, level).then(() => {
this.init();
});
},
changeOrder(orderProp: string): void {
this.orderProp = orderProp;
this.reverse = !this.reverse;
},
extractLoggers(response) {
this.loggers = [];
if (response.data) {
for (const key of Object.keys(response.data.loggers)) {
const logger = response.data.loggers[key];
this.loggers.push({ name: key, level: logger.effectiveLevel });
}
}
},
},
});

View File

@@ -0,0 +1,11 @@
import axios, { type AxiosPromise } from 'axios';
export default class LogsService {
changeLevel(name: string, configuredLevel: string): AxiosPromise<any> {
return axios.post(`management/loggers/${name}`, { configuredLevel });
}
findAll(): AxiosPromise<any> {
return axios.get('management/loggers');
}
}

View File

@@ -0,0 +1,56 @@
<template>
<div class="table-responsive">
<h2 id="logs-page-heading" data-cy="logsPageHeading">{{ t$('logs.title') }}</h2>
<div v-if="loggers">
<p>{{ t$('logs.nbloggers', { total: loggers.length }) }}</p>
<span>{{ t$('logs.filter') }}</span> <input type="text" v-model="filtered" class="form-control" />
<table class="table table-sm table-striped table-bordered" aria-describedby="Logs">
<thead>
<tr title="click to order">
<th @click="changeOrder('name')" scope="col">
<span>{{ t$('logs.table.name') }}</span>
<jhi-sort-indicator :current-order="orderProp" :reverse="reverse" :field-name="'name'"></jhi-sort-indicator>
</th>
<th @click="changeOrder('level')" scope="col">
<span>{{ t$('logs.table.level') }}</span>
<jhi-sort-indicator :current-order="orderProp" :reverse="reverse" :field-name="'level'"></jhi-sort-indicator>
</th>
</tr>
</thead>
<tr v-for="logger in filteredLoggers" :key="logger.name">
<td>
<small>{{ logger.name }}</small>
</td>
<td>
<BButtonGroup role="group" aria-label="Log level" size="sm" class="flex-nowrap">
<BButton @click="updateLevel(logger.name, 'TRACE')" :variant="logger.level === 'TRACE' ? 'primary' : 'light'" size="sm">
TRACE
</BButton>
<BButton @click="updateLevel(logger.name, 'DEBUG')" :variant="logger.level === 'DEBUG' ? 'success' : 'light'" size="sm">
DEBUG
</BButton>
<BButton @click="updateLevel(logger.name, 'INFO')" :variant="logger.level === 'INFO' ? 'info' : 'light'" size="sm">
INFO
</BButton>
<BButton @click="updateLevel(logger.name, 'WARN')" :variant="logger.level === 'WARN' ? 'warning' : 'light'" size="sm">
WARN
</BButton>
<BButton @click="updateLevel(logger.name, 'ERROR')" :variant="logger.level === 'ERROR' ? 'danger' : 'light'" size="sm">
ERROR
</BButton>
<BButton @click="updateLevel(logger.name, 'OFF')" :variant="logger.level === 'OFF' ? 'secondary' : 'light'" size="sm">
OFF
</BButton>
</BButtonGroup>
</td>
</tr>
</table>
</div>
</div>
</template>
<script lang="ts" src="./logs.component.ts"></script>

View File

@@ -0,0 +1,57 @@
import { shallowMount } from '@vue/test-utils';
import MetricsModal from './metrics-modal.vue';
type MetricsModalComponentType = InstanceType<typeof MetricsModal>;
describe('Metrics Component', () => {
let metricsModal: MetricsModalComponentType;
beforeEach(() => {
const wrapper = shallowMount(MetricsModal, {
propsData: {
threadDump: [
{ name: 'test1', threadState: 'RUNNABLE' },
{ name: 'test2', threadState: 'WAITING' },
{ name: 'test3', threadState: 'TIMED_WAITING' },
{ name: 'test4', threadState: 'BLOCKED' },
{ name: 'test5', threadState: 'BLOCKED' },
{ name: 'test5', threadState: 'NONE' },
],
},
});
metricsModal = wrapper.vm;
});
describe('init', () => {
it('should count the numbers of each thread type', () => {
expect(metricsModal.threadDumpData.threadDumpRunnable).toBe(1);
expect(metricsModal.threadDumpData.threadDumpWaiting).toBe(1);
expect(metricsModal.threadDumpData.threadDumpTimedWaiting).toBe(1);
expect(metricsModal.threadDumpData.threadDumpBlocked).toBe(2);
expect(metricsModal.threadDumpData.threadDumpAll).toBe(5);
});
});
describe('getBadgeClass', () => {
it('should return bg-success for RUNNABLE', () => {
expect(metricsModal.getBadgeClass('RUNNABLE')).toBe('bg-success');
});
it('should return bg-info for WAITING', () => {
expect(metricsModal.getBadgeClass('WAITING')).toBe('bg-info');
});
it('should return bg-warning for TIMED_WAITING', () => {
expect(metricsModal.getBadgeClass('TIMED_WAITING')).toBe('bg-warning');
});
it('should return bg-danger for BLOCKED', () => {
expect(metricsModal.getBadgeClass('BLOCKED')).toBe('bg-danger');
});
it('should return an empty string for anything else', () => {
expect(metricsModal.getBadgeClass('')).toBe('');
});
});
});

View File

@@ -0,0 +1,64 @@
import { type PropType, type Ref, computed, defineComponent, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { filterBy } from '@/shared/computables';
export default defineComponent({
name: 'JhiMetricsModal',
props: {
threadDump: {
type: Array as PropType<any[]>,
default: () => [],
},
},
setup(props) {
const threadDumpFilter: Ref<any> = ref('');
const filteredThreadDump = computed(() => filterBy(props.threadDump, { filterByTerm: threadDumpFilter.value }));
const threadDumpData = computed(() => {
const data = {
threadDumpAll: 0,
threadDumpBlocked: 0,
threadDumpRunnable: 0,
threadDumpTimedWaiting: 0,
threadDumpWaiting: 0,
};
if (props.threadDump) {
props.threadDump.forEach(value => {
if (value.threadState === 'RUNNABLE') {
data.threadDumpRunnable += 1;
} else if (value.threadState === 'WAITING') {
data.threadDumpWaiting += 1;
} else if (value.threadState === 'TIMED_WAITING') {
data.threadDumpTimedWaiting += 1;
} else if (value.threadState === 'BLOCKED') {
data.threadDumpBlocked += 1;
}
});
data.threadDumpAll = data.threadDumpRunnable + data.threadDumpWaiting + data.threadDumpTimedWaiting + data.threadDumpBlocked;
}
return data;
});
return {
threadDumpFilter,
threadDumpData,
filteredThreadDump,
t$: useI18n().t,
};
},
methods: {
getBadgeClass(threadState: string): string {
if (threadState === 'RUNNABLE') {
return 'bg-success';
} else if (threadState === 'WAITING') {
return 'bg-info';
} else if (threadState === 'TIMED_WAITING') {
return 'bg-warning';
} else if (threadState === 'BLOCKED') {
return 'bg-danger';
}
return '';
},
},
});

View File

@@ -0,0 +1,67 @@
<template>
<div class="modal-body">
<span class="badge bg-primary" @click="threadDumpFilter = ''"
>All&nbsp;<span class="badge rounded-pill bg-default">{{ threadDumpData.threadDumpAll }}</span></span
>&nbsp;
<span class="badge bg-success" @click="threadDumpFilter = 'RUNNABLE'"
>Runnable&nbsp;<span class="badge rounded-pill bg-default">{{ threadDumpData.threadDumpRunnable }}</span></span
>&nbsp;
<span class="badge bg-info" @click="threadDumpFilter = 'WAITING'"
>Waiting&nbsp;<span class="badge rounded-pill bg-default">{{ threadDumpData.threadDumpWaiting }}</span></span
>&nbsp;
<span class="badge bg-warning" @click="threadDumpFilter = 'TIMED_WAITING'"
>Timed Waiting&nbsp;<span class="badge rounded-pill bg-default">{{ threadDumpData.threadDumpTimedWaiting }}</span></span
>&nbsp;
<span class="badge bg-danger" @click="threadDumpFilter = 'BLOCKED'"
>Blocked&nbsp;<span class="badge rounded-pill bg-default">{{ threadDumpData.threadDumpBlocked }}</span></span
>&nbsp;
<div class="mt-2">&nbsp;</div>
Filter
<input type="text" v-model="threadDumpFilter" class="form-control" />
<div class="pad" v-for="(entry, key1) of filteredThreadDump" :key="key1">
<h6>
<span class="badge" :class="getBadgeClass(entry.threadState)">{{ entry.threadState }}</span
>&nbsp;{{ entry.threadName }} (ID {{ entry.threadId }})
<a @click="entry.show = !entry.show" href="javascript:void(0);">
<span :hidden="entry.show">{{ t$('metrics.jvm.threads.dump.show') }}</span>
<span :hidden="!entry.show">{{ t$('metrics.jvm.threads.dump.hide') }}</span>
</a>
</h6>
<div class="card" :hidden="!entry.show">
<div class="card-body">
<div v-for="(st, key2) of entry.stackTrace" :key="key2" class="break">
<samp
>{{ st.className }}.{{ st.methodName }}(<code>{{ st.fileName }}:{{ st.lineNumber }}</code
>)</samp
>
<span class="mt-1"></span>
</div>
</div>
</div>
<table class="table table-sm table-responsive" aria-describedby="Metrics">
<thead>
<tr>
<th scope="col">{{ t$('metrics.jvm.threads.dump.blockedtime') }}</th>
<th scope="col">{{ t$('metrics.jvm.threads.dump.blockedcount') }}</th>
<th scope="col">{{ t$('metrics.jvm.threads.dump.waitedtime') }}</th>
<th scope="col">{{ t$('metrics.jvm.threads.dump.waitedcount') }}</th>
<th scope="col">{{ t$('metrics.jvm.threads.dump.lockname') }}</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ entry.blockedTime }}</td>
<td>{{ entry.blockedCount }}</td>
<td>{{ entry.waitedTime }}</td>
<td>{{ entry.waitedCount }}</td>
<td class="thread-dump-modal-lock" :title="entry.lockName">
<code>{{ entry.lockName }}</code>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script lang="ts" src="./metrics-modal.component.ts"></script>

View File

@@ -0,0 +1,271 @@
import { shallowMount } from '@vue/test-utils';
import axios from 'axios';
import sinon from 'sinon';
import MetricsService from './metrics.service';
import Metrics from './metrics.vue';
type MetricsComponentType = InstanceType<typeof Metrics>;
const axiosStub = {
get: sinon.stub(axios, 'get'),
};
describe('Metrics Component', () => {
let metricsComponent: MetricsComponentType;
const response = {
jvm: {
'PS Eden Space': {
committed: 5.57842432e8,
max: 6.49592832e8,
used: 4.20828184e8,
},
'Code Cache': {
committed: 2.3461888e7,
max: 2.5165824e8,
used: 2.2594368e7,
},
'Compressed Class Space': {
committed: 1.2320768e7,
max: 1.073741824e9,
used: 1.1514008e7,
},
'PS Survivor Space': {
committed: 1.5204352e7,
max: 1.5204352e7,
used: 1.2244376e7,
},
'PS Old Gen': {
committed: 1.10624768e8,
max: 1.37887744e9,
used: 4.1390776e7,
},
Metaspace: {
committed: 9.170944e7,
max: -1.0,
used: 8.7377552e7,
},
},
databases: {
min: {
value: 10.0,
},
max: {
value: 10.0,
},
idle: {
value: 10.0,
},
usage: {
'0.0': 0.0,
'1.0': 0.0,
max: 0.0,
totalTime: 4210.0,
mean: 701.6666666666666,
'0.5': 0.0,
count: 6,
'0.99': 0.0,
'0.75': 0.0,
'0.95': 0.0,
},
pending: {
value: 0.0,
},
active: {
value: 0.0,
},
acquire: {
'0.0': 0.0,
'1.0': 0.0,
max: 0.0,
totalTime: 0.884426,
mean: 0.14740433333333333,
'0.5': 0.0,
count: 6,
'0.99': 0.0,
'0.75': 0.0,
'0.95': 0.0,
},
creation: {
'0.0': 0.0,
'1.0': 0.0,
max: 0.0,
totalTime: 27.0,
mean: 3.0,
'0.5': 0.0,
count: 9,
'0.99': 0.0,
'0.75': 0.0,
'0.95': 0.0,
},
connections: {
value: 10.0,
},
},
'http.server.requests': {
all: {
count: 5,
},
percode: {
'200': {
max: 0.0,
mean: 298.9012628,
count: 5,
},
},
},
cache: {
usersByEmail: {
'cache.gets.miss': 0.0,
'cache.puts': 0.0,
'cache.gets.hit': 0.0,
'cache.removals': 0.0,
'cache.evictions': 0.0,
},
usersByLogin: {
'cache.gets.miss': 1.0,
'cache.puts': 1.0,
'cache.gets.hit': 1.0,
'cache.removals': 0.0,
'cache.evictions': 0.0,
},
'tech.jhipster.domain.Authority': {
'cache.gets.miss': 0.0,
'cache.puts': 2.0,
'cache.gets.hit': 0.0,
'cache.removals': 0.0,
'cache.evictions': 0.0,
},
'tech.jhipster.domain.User.authorities': {
'cache.gets.miss': 0.0,
'cache.puts': 1.0,
'cache.gets.hit': 0.0,
'cache.removals': 0.0,
'cache.evictions': 0.0,
},
'tech.jhipster.domain.User': {
'cache.gets.miss': 0.0,
'cache.puts': 1.0,
'cache.gets.hit': 0.0,
'cache.removals': 0.0,
'cache.evictions': 0.0,
},
},
garbageCollector: {
'jvm.gc.max.data.size': 1.37887744e9,
'jvm.gc.pause': {
'0.0': 0.0,
'1.0': 0.0,
max: 0.0,
totalTime: 242.0,
mean: 242.0,
'0.5': 0.0,
count: 1,
'0.99': 0.0,
'0.75': 0.0,
'0.95': 0.0,
},
'jvm.gc.memory.promoted': 2.992732e7,
'jvm.gc.memory.allocated': 1.26362872e9,
classesLoaded: 17393.0,
'jvm.gc.live.data.size': 3.1554408e7,
classesUnloaded: 0.0,
},
services: {
'/management/info': {
GET: {
max: 0.0,
mean: 104.952893,
count: 1,
},
},
'/api/authenticate': {
POST: {
max: 0.0,
mean: 909.53003,
count: 1,
},
},
'/api/account': {
GET: {
max: 0.0,
mean: 141.209628,
count: 1,
},
},
'/**': {
GET: {
max: 0.0,
mean: 169.4068815,
count: 2,
},
},
},
processMetrics: {
'system.load.average.1m': 3.63,
'system.cpu.usage': 0.5724934148485453,
'system.cpu.count': 4.0,
'process.start.time': 1.548140811306e12,
'process.files.open': 205.0,
'process.cpu.usage': 0.003456347568026252,
'process.uptime': 88404.0,
'process.files.max': 1048576.0,
},
threads: [{ name: 'test1', threadState: 'RUNNABLE' }],
};
beforeEach(() => {
axiosStub.get.resolves({ data: { timers: [], gauges: [] } });
const wrapper = shallowMount(Metrics, {
global: {
stubs: {
bModal: true,
bProgress: true,
bProgressBar: true,
'font-awesome-icon': true,
'metrics-modal': true,
},
directives: {
'b-modal': {},
'b-progress': {},
'b-progress-bar': {},
},
provide: {
metricsService: new MetricsService(),
},
},
});
metricsComponent = wrapper.vm;
});
describe('refresh', () => {
it('should call refresh on init', async () => {
// GIVEN
axiosStub.get.resolves({ data: response });
// WHEN
await metricsComponent.refresh();
await metricsComponent.$nextTick();
// THEN
expect(axiosStub.get.calledWith('management/jhimetrics')).toBeTruthy();
expect(axiosStub.get.calledWith('management/threaddump')).toBeTruthy();
expect(metricsComponent.metrics).toHaveProperty('jvm');
expect(metricsComponent.metrics).toEqual(response);
expect(metricsComponent.threadStats).toEqual({
threadDumpRunnable: 1,
threadDumpWaiting: 0,
threadDumpTimedWaiting: 0,
threadDumpBlocked: 0,
threadDumpAll: 1,
});
});
});
describe('isNan', () => {
it('should return if a variable is NaN', () => {
expect(metricsComponent.filterNaN(1)).toBe(1);
expect(metricsComponent.filterNaN('test')).toBe(0);
});
});
});

View File

@@ -0,0 +1,134 @@
import { type Ref, defineComponent, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import numeral from 'numeral';
import { useDateFormat } from '@/shared/composables';
import JhiMetricsModal from './metrics-modal.vue';
import MetricsService from './metrics.service';
export default defineComponent({
name: 'JhiMetrics',
components: {
'metrics-modal': JhiMetricsModal,
},
setup() {
const { formatDate } = useDateFormat();
const metricsService = inject('metricsService', () => new MetricsService(), true);
const metrics: Ref<any> = ref({});
const threadData: Ref<any> = ref(null);
const threadStats: Ref<any> = ref({});
const updatingMetrics = ref(true);
return {
metricsService,
metrics,
threadData,
threadStats,
updatingMetrics,
formatDate,
t$: useI18n().t,
};
},
mounted(): void {
this.refresh();
},
methods: {
refresh() {
return this.metricsService
.getMetrics()
.then(resultsMetrics => {
this.metrics = resultsMetrics.data;
this.metricsService
.retrieveThreadDump()
.then(res => {
this.updatingMetrics = true;
this.threadData = res.data.threads;
this.threadStats = {
threadDumpRunnable: 0,
threadDumpWaiting: 0,
threadDumpTimedWaiting: 0,
threadDumpBlocked: 0,
threadDumpAll: 0,
};
this.threadData.forEach(value => {
if (value.threadState === 'RUNNABLE') {
this.threadStats.threadDumpRunnable += 1;
} else if (value.threadState === 'WAITING') {
this.threadStats.threadDumpWaiting += 1;
} else if (value.threadState === 'TIMED_WAITING') {
this.threadStats.threadDumpTimedWaiting += 1;
} else if (value.threadState === 'BLOCKED') {
this.threadStats.threadDumpBlocked += 1;
}
});
this.threadStats.threadDumpAll =
this.threadStats.threadDumpRunnable +
this.threadStats.threadDumpWaiting +
this.threadStats.threadDumpTimedWaiting +
this.threadStats.threadDumpBlocked;
this.updatingMetrics = false;
})
.catch(() => {
this.updatingMetrics = true;
});
})
.catch(() => {
this.updatingMetrics = true;
});
},
openModal(): void {
if ((<any>this.$refs.metricsModal).show) {
(<any>this.$refs.metricsModal).show();
}
},
filterNaN(input: any): any {
if (isNaN(input)) {
return 0;
}
return input;
},
formatNumber1(value: any): any {
return numeral(value).format('0,0');
},
formatNumber2(value: any): any {
return numeral(value).format('0,00');
},
convertMillisecondsToDuration(ms) {
const times = {
year: 31557600000,
month: 2629746000,
day: 86400000,
hour: 3600000,
minute: 60000,
second: 1000,
};
let time_string = '';
let plural = '';
for (const key in times) {
if (Math.floor(ms / times[key]) > 0) {
if (Math.floor(ms / times[key]) > 1) {
plural = 's';
} else {
plural = '';
}
time_string += `${Math.floor(ms / times[key])} ${key}${plural} `;
ms = ms - times[key] * Math.floor(ms / times[key]);
}
}
return time_string;
},
isObjectExisting(metrics: any, key: string): boolean {
return metrics?.[key];
},
isObjectExistingAndNotEmpty(metrics: any, key: string): boolean {
return this.isObjectExisting(metrics, key) && JSON.stringify(metrics[key]) !== '{}';
},
},
});

View File

@@ -0,0 +1,11 @@
import axios, { type AxiosPromise } from 'axios';
export default class MetricsService {
getMetrics(): AxiosPromise<any> {
return axios.get('management/jhimetrics');
}
retrieveThreadDump(): AxiosPromise<any> {
return axios.get('management/threaddump');
}
}

View File

@@ -0,0 +1,371 @@
<template>
<div>
<h2>
<span id="metrics-page-heading" data-cy="metricsPageHeading">{{ t$('metrics.title') }}</span>
<button class="btn btn-primary float-end" @click="refresh()">
<font-awesome-icon icon="sync"></font-awesome-icon> <span>{{ t$("metrics['refresh.button']") }}</span>
</button>
</h2>
<h3>{{ t$('metrics.jvm.title') }}</h3>
<div class="row" v-if="!updatingMetrics">
<div class="col-md-4">
<h4>{{ t$('metrics.jvm.memory.title') }}</h4>
<div>
<div v-for="(entry, key) of metrics.jvm" :key="key">
<span v-if="entry.max !== -1">
<span>{{ key }}</span> ({{ formatNumber1(entry.used / 1048576) }}M / {{ formatNumber1(entry.max / 1048576) }}M)
</span>
<span v-else>
<span>{{ key }}</span> {{ formatNumber1(entry.used / 1048576) }}M
</span>
<div>Committed : {{ formatNumber1(entry.committed / 1048576) }}M</div>
<b-progress v-if="entry.max !== -1" variant="success" animated :max="entry.max" striped>
<b-progress-bar :value="entry.used" :label="formatNumber1((entry.used * 100) / entry.max) + '%'"> </b-progress-bar>
</b-progress>
</div>
</div>
</div>
<div class="col-md-4">
<h4>{{ t$('metrics.jvm.threads.title') }}</h4>
<span
><span>{{ t$('metrics.jvm.threads.runnable') }}</span> {{ threadStats.threadDumpRunnable }}</span
>
<b-progress variant="success" :max="threadStats.threadDumpAll" striped>
<b-progress-bar
:value="threadStats.threadDumpRunnable"
:label="formatNumber1((threadStats.threadDumpRunnable * 100) / threadStats.threadDumpAll) + '%'"
>
</b-progress-bar>
</b-progress>
<span
><span>{{ t$('metrics.jvm.threads.timedwaiting') }}</span> ({{ threadStats.threadDumpTimedWaiting }})</span
>
<b-progress variant="success" :max="threadStats.threadDumpAll" striped>
<b-progress-bar
:value="threadStats.threadDumpTimedWaiting"
:label="formatNumber1((threadStats.threadDumpTimedWaiting * 100) / threadStats.threadDumpAll) + '%'"
>
</b-progress-bar>
</b-progress>
<span
><span>{{ t$('metrics.jvm.threads.waiting') }}</span> ({{ threadStats.threadDumpWaiting }})</span
>
<b-progress variant="success" :max="threadStats.threadDumpAll" striped>
<b-progress-bar
:value="threadStats.threadDumpWaiting"
:label="formatNumber1((threadStats.threadDumpWaiting * 100) / threadStats.threadDumpAll) + '%'"
>
</b-progress-bar>
</b-progress>
<span
><span>{{ t$('metrics.jvm.threads.blocked') }}</span> ({{ threadStats.threadDumpBlocked }})</span
>
<b-progress variant="success" :max="threadStats.threadDumpAll" striped>
<b-progress-bar
:value="threadStats.threadDumpBlocked"
:label="formatNumber1((threadStats.threadDumpBlocked * 100) / threadStats.threadDumpAll) + '%'"
>
</b-progress-bar>
</b-progress>
<span
>Total: {{ threadStats.threadDumpAll }}
<a class="hand" v-b-modal.metricsModal data-toggle="modal" @click="openModal()" data-target="#threadDump">
<font-awesome-icon icon="eye"></font-awesome-icon>
</a>
</span>
</div>
<div class="col-md-4">
<h4>System</h4>
<div class="row" v-if="!updatingMetrics">
<div class="col-md-4">Uptime</div>
<div class="col-md-8 text-end">{{ convertMillisecondsToDuration(metrics.processMetrics['process.uptime']) }}</div>
</div>
<div class="row" v-if="!updatingMetrics">
<div class="col-md-4">Start time</div>
<div class="col-md-8 text-end">{{ formatDate(metrics.processMetrics['process.start.time']) }}</div>
</div>
<div class="row" v-if="!updatingMetrics">
<div class="col-md-9">Process CPU usage</div>
<div class="col-md-3 text-end">{{ formatNumber2(100 * metrics.processMetrics['process.cpu.usage']) }} %</div>
</div>
<b-progress variant="success" :max="100" striped>
<b-progress-bar
:value="100 * metrics.processMetrics['process.cpu.usage']"
:label="formatNumber1(100 * metrics.processMetrics['process.cpu.usage']) + '%'"
>
</b-progress-bar>
</b-progress>
<div class="row" v-if="!updatingMetrics">
<div class="col-md-9">System CPU usage</div>
<div class="col-md-3 text-end">{{ formatNumber2(100 * metrics.processMetrics['system.cpu.usage']) }} %</div>
</div>
<b-progress variant="success" :max="100" striped>
<b-progress-bar
:value="100 * metrics.processMetrics['system.cpu.usage']"
:label="formatNumber1(100 * metrics.processMetrics['system.cpu.usage']) + '%'"
>
</b-progress-bar>
</b-progress>
<div class="row" v-if="!updatingMetrics">
<div class="col-md-9">System CPU count</div>
<div class="col-md-3 text-end">{{ metrics.processMetrics['system.cpu.count'] }}</div>
</div>
<div class="row" v-if="!updatingMetrics">
<div class="col-md-9">System 1m Load average</div>
<div class="col-md-3 text-end">{{ formatNumber2(metrics.processMetrics['system.load.average.1m']) }}</div>
</div>
<div class="row" v-if="!updatingMetrics">
<div class="col-md-9">Process files max</div>
<div class="col-md-3 text-end">{{ formatNumber1(metrics.processMetrics['process.files.max']) }}</div>
</div>
<div class="row" v-if="!updatingMetrics">
<div class="col-md-9">Process files open</div>
<div class="col-md-3 text-end">{{ formatNumber1(metrics.processMetrics['process.files.open']) }}</div>
</div>
</div>
</div>
<h3>{{ t$('metrics.jvm.gc.title') }}</h3>
<div class="row" v-if="!updatingMetrics && isObjectExisting(metrics, 'garbageCollector')">
<div class="col-md-4">
<div>
<span>
GC Live Data Size/GC Max Data Size ({{ formatNumber1(metrics.garbageCollector['jvm.gc.live.data.size'] / 1048576) }}M /
{{ formatNumber1(metrics.garbageCollector['jvm.gc.max.data.size'] / 1048576) }}M)
</span>
<b-progress variant="success" :max="metrics.garbageCollector['jvm.gc.max.data.size']" striped>
<b-progress-bar
:value="metrics.garbageCollector['jvm.gc.live.data.size']"
:label="
formatNumber2(
(100 * metrics.garbageCollector['jvm.gc.live.data.size']) / metrics.garbageCollector['jvm.gc.max.data.size'],
) + '%'
"
>
</b-progress-bar>
</b-progress>
</div>
</div>
<div class="col-md-4">
<div>
<span>
GC Memory Promoted/GC Memory Allocated ({{ formatNumber1(metrics.garbageCollector['jvm.gc.memory.promoted'] / 1048576) }}M /
{{ formatNumber1(metrics.garbageCollector['jvm.gc.memory.allocated'] / 1048576) }}M)
</span>
<b-progress variant="success" :max="metrics.garbageCollector['jvm.gc.memory.allocated']" striped>
<b-progress-bar
:value="metrics.garbageCollector['jvm.gc.memory.promoted']"
:label="
formatNumber2(
(100 * metrics.garbageCollector['jvm.gc.memory.promoted']) / metrics.garbageCollector['jvm.gc.memory.allocated'],
) + '%'
"
>
</b-progress-bar>
</b-progress>
</div>
</div>
<div class="col-md-4">
<div class="row">
<div class="col-md-9">Classes loaded</div>
<div class="col-md-3 text-end">{{ metrics.garbageCollector.classesLoaded }}</div>
</div>
<div class="row">
<div class="col-md-9">Classes unloaded</div>
<div class="col-md-3 text-end">{{ metrics.garbageCollector.classesUnloaded }}</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-striped" aria-describedby="Jvm gc">
<thead>
<tr>
<th scope="col"></th>
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.count') }}</th>
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.mean') }}</th>
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.min') }}</th>
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.p50') }}</th>
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.p75') }}</th>
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.p95') }}</th>
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.p99') }}</th>
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.max') }}</th>
</tr>
</thead>
<tbody>
<tr>
<td>jvm.gc.pause</td>
<td class="text-end">{{ metrics.garbageCollector['jvm.gc.pause'].count }}</td>
<td class="text-end">{{ formatNumber2(metrics.garbageCollector['jvm.gc.pause'].mean) }}</td>
<td class="text-end">{{ formatNumber2(metrics.garbageCollector['jvm.gc.pause']['0.0']) }}</td>
<td class="text-end">{{ formatNumber2(metrics.garbageCollector['jvm.gc.pause']['0.5']) }}</td>
<td class="text-end">{{ formatNumber2(metrics.garbageCollector['jvm.gc.pause']['0.75']) }}</td>
<td class="text-end">{{ formatNumber2(metrics.garbageCollector['jvm.gc.pause']['0.95']) }}</td>
<td class="text-end">{{ formatNumber2(metrics.garbageCollector['jvm.gc.pause']['0.99']) }}</td>
<td class="text-end">{{ formatNumber2(metrics.garbageCollector['jvm.gc.pause'].max) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<h3>{{ t$('metrics.jvm.http.title') }}</h3>
<table
class="table table-striped"
v-if="!updatingMetrics && isObjectExisting(metrics, 'http.server.requests')"
aria-describedby="Jvm http"
>
<thead>
<tr>
<th scope="col">{{ t$('metrics.jvm.http.table.code') }}</th>
<th scope="col">{{ t$('metrics.jvm.http.table.count') }}</th>
<th scope="col" class="text-end">{{ t$('metrics.jvm.http.table.mean') }}</th>
<th scope="col" class="text-end">{{ t$('metrics.jvm.http.table.max') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(entry, key) of metrics['http.server.requests']['percode']" :key="key">
<td>{{ key }}</td>
<td>
<b-progress variant="success" animated :max="metrics['http.server.requests']['all'].count" striped>
<b-progress-bar :value="entry.count" :label="formatNumber1(entry.count)"></b-progress-bar>
</b-progress>
</td>
<td class="text-end">
{{ formatNumber2(filterNaN(entry.mean)) }}
</td>
<td class="text-end">{{ formatNumber2(entry.max) }}</td>
</tr>
</tbody>
</table>
<h3>Endpoints requests (time in millisecond)</h3>
<div class="table-responsive" v-if="!updatingMetrics">
<table class="table table-striped" aria-describedby="Endpoint">
<thead>
<tr>
<th scope="col">Method</th>
<th scope="col">Endpoint url</th>
<th scope="col" class="text-end">Count</th>
<th scope="col" class="text-end">Mean</th>
</tr>
</thead>
<tbody>
<template v-for="(entry, entryKey) of metrics.services">
<tr v-for="(method, methodKey) of entry" :key="entryKey + '-' + methodKey">
<td>{{ methodKey }}</td>
<td>{{ entryKey }}</td>
<td class="text-end">{{ method.count }}</td>
<td class="text-end">{{ formatNumber2(method.mean) }}</td>
</tr>
</template>
</tbody>
</table>
</div>
<h3>{{ t$('metrics.cache.title') }}</h3>
<div class="table-responsive" v-if="!updatingMetrics && isObjectExisting(metrics, 'cache')">
<table class="table table-striped" aria-describedby="Cache">
<thead>
<tr>
<th scope="col">{{ t$('metrics.cache.cachename') }}</th>
<th scope="col" class="text-end" data-translate="metrics.cache.hits">Cache Hits</th>
<th scope="col" class="text-end" data-translate="metrics.cache.misses">Cache Misses</th>
<th scope="col" class="text-end" data-translate="metrics.cache.gets">Cache Gets</th>
<th scope="col" class="text-end" data-translate="metrics.cache.puts">Cache Puts</th>
<th scope="col" class="text-end" data-translate="metrics.cache.removals">Cache Removals</th>
<th scope="col" class="text-end" data-translate="metrics.cache.evictions">Cache Evictions</th>
<th scope="col" class="text-end" data-translate="metrics.cache.hitPercent">Cache Hit %</th>
<th scope="col" class="text-end" data-translate="metrics.cache.missPercent">Cache Miss %</th>
</tr>
</thead>
<tbody>
<tr v-for="(entry, key) of metrics.cache" :key="key">
<td>{{ key }}</td>
<td class="text-end">{{ entry['cache.gets.hit'] }}</td>
<td class="text-end">{{ entry['cache.gets.miss'] }}</td>
<td class="text-end">{{ entry['cache.gets.hit'] + entry['cache.gets.miss'] }}</td>
<td class="text-end">{{ entry['cache.puts'] }}</td>
<td class="text-end">{{ entry['cache.removals'] }}</td>
<td class="text-end">{{ entry['cache.evictions'] }}</td>
<td class="text-end">
{{ formatNumber2(filterNaN((100 * entry['cache.gets.hit']) / (entry['cache.gets.hit'] + entry['cache.gets.miss']))) }}
</td>
<td class="text-end">
{{ formatNumber2(filterNaN((100 * entry['cache.gets.miss']) / (entry['cache.gets.hit'] + entry['cache.gets.miss']))) }}
</td>
</tr>
</tbody>
</table>
</div>
<h3>{{ t$('metrics.datasource.title') }}</h3>
<div class="table-responsive" v-if="!updatingMetrics && isObjectExistingAndNotEmpty(metrics, 'databases')">
<table class="table table-striped" aria-describedby="Connection pool">
<thead>
<tr>
<th scope="col">
<span>{{ t$('metrics.datasource.usage') }}</span> (active: {{ metrics.databases.active.value }}, min:
{{ metrics.databases.min.value }}, max: {{ metrics.databases.max.value }}, idle: {{ metrics.databases.idle.value }})
</th>
<th scope="col" class="text-end">{{ t$('metrics.datasource.count') }}</th>
<th scope="col" class="text-end">{{ t$('metrics.datasource.mean') }}</th>
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.min') }}</th>
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.p50') }}</th>
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.p75') }}</th>
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.p95') }}</th>
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.p99') }}</th>
<th scope="col" class="text-end">{{ t$('metrics.datasource.max') }}</th>
</tr>
</thead>
<tbody>
<tr>
<td>Acquire</td>
<td class="text-end">{{ metrics.databases.acquire.count }}</td>
<td class="text-end">{{ formatNumber2(filterNaN(metrics.databases.acquire.mean)) }}</td>
<td class="text-end">{{ formatNumber2(metrics.databases.acquire['0.0']) }}</td>
<td class="text-end">{{ formatNumber2(metrics.databases.acquire['0.5']) }}</td>
<td class="text-end">{{ formatNumber2(metrics.databases.acquire['0.75']) }}</td>
<td class="text-end">{{ formatNumber2(metrics.databases.acquire['0.95']) }}</td>
<td class="text-end">{{ formatNumber2(metrics.databases.acquire['0.99']) }}</td>
<td class="text-end">{{ formatNumber2(filterNaN(metrics.databases.acquire.max)) }}</td>
</tr>
<tr>
<td>Creation</td>
<td class="text-end">{{ metrics.databases.creation.count }}</td>
<td class="text-end">{{ formatNumber2(filterNaN(metrics.databases.creation.mean)) }}</td>
<td class="text-end">{{ formatNumber2(metrics.databases.creation['0.0']) }}</td>
<td class="text-end">{{ formatNumber2(metrics.databases.creation['0.5']) }}</td>
<td class="text-end">{{ formatNumber2(metrics.databases.creation['0.75']) }}</td>
<td class="text-end">{{ formatNumber2(metrics.databases.creation['0.95']) }}</td>
<td class="text-end">{{ formatNumber2(metrics.databases.creation['0.99']) }}</td>
<td class="text-end">{{ formatNumber2(filterNaN(metrics.databases.creation.max)) }}</td>
</tr>
<tr>
<td>Usage</td>
<td class="text-end">{{ metrics.databases.usage.count }}</td>
<td class="text-end">{{ formatNumber2(filterNaN(metrics.databases.usage.mean)) }}</td>
<td class="text-end">{{ formatNumber2(metrics.databases.usage['0.0']) }}</td>
<td class="text-end">{{ formatNumber2(metrics.databases.usage['0.5']) }}</td>
<td class="text-end">{{ formatNumber2(metrics.databases.usage['0.75']) }}</td>
<td class="text-end">{{ formatNumber2(metrics.databases.usage['0.95']) }}</td>
<td class="text-end">{{ formatNumber2(metrics.databases.usage['0.99']) }}</td>
<td class="text-end">{{ formatNumber2(filterNaN(metrics.databases.usage.max)) }}</td>
</tr>
</tbody>
</table>
</div>
<b-modal ref="metricsModal" size="lg" ok-only>
<template #title>
<h4 class="modal-title" id="showMetricsLabel">{{ t$('metrics.jvm.threads.dump.title') }}</h4>
</template>
<metrics-modal :thread-dump="threadData"></metrics-modal>
</b-modal>
</div>
</template>
<script lang="ts" src="./metrics.component.ts"></script>

View File

@@ -0,0 +1,155 @@
import { vitest } from 'vitest';
import { type RouteLocation } from 'vue-router';
import { type MountingOptions, shallowMount } from '@vue/test-utils';
import axios from 'axios';
import sinon from 'sinon';
import AlertService from '@/shared/alert/alert.service';
import UserManagementEdit from './user-management-edit.vue';
type UserManagementEditComponentType = InstanceType<typeof UserManagementEdit>;
let route: Partial<RouteLocation>;
const routerGoMock = vitest.fn();
vitest.mock('vue-router', () => ({
useRoute: () => route,
useRouter: () => ({ go: routerGoMock }),
}));
describe('UserManagementEdit Component', () => {
const axiosStub = {
get: sinon.stub(axios, 'get'),
post: sinon.stub(axios, 'post'),
put: sinon.stub(axios, 'put'),
};
let mountOptions: MountingOptions<UserManagementEditComponentType>['global'];
let alertService: AlertService;
beforeEach(() => {
route = {};
alertService = new AlertService({
i18n: { t: vitest.fn() } as any,
toast: {
show: vitest.fn(),
} as any,
});
mountOptions = {
stubs: {
'font-awesome-icon': true,
},
provide: {
alertService,
},
};
axiosStub.get.reset();
axiosStub.post.reset();
axiosStub.put.reset();
});
describe('init', () => {
it('Should load user', async () => {
// GIVEN
axiosStub.get.withArgs(`api/admin/users/${123}`).resolves({});
axiosStub.get.withArgs('api/authorities').resolves({ data: [] });
route = {
params: {
userId: `${123}`,
},
};
const wrapper = shallowMount(UserManagementEdit, { global: mountOptions });
const userManagementEdit: UserManagementEditComponentType = wrapper.vm;
// WHEN
await userManagementEdit.$nextTick();
// THEN
expect(axiosStub.get.calledWith('api/authorities')).toBeTruthy();
expect(axiosStub.get.calledWith(`api/admin/users/${123}`)).toBeTruthy();
});
it('Should open create user', async () => {
// GIVEN
axiosStub.get.resolves({});
axiosStub.get.withArgs('api/authorities').resolves({ data: [] });
route = {
params: {},
};
const wrapper = shallowMount(UserManagementEdit, { global: mountOptions });
const userManagementEdit: UserManagementEditComponentType = wrapper.vm;
// WHEN
await userManagementEdit.$nextTick();
// THEN
expect(axiosStub.get.calledWith('api/authorities')).toBeTruthy();
expect(axiosStub.get.callCount).toBe(1);
});
});
describe('save', () => {
it('Should call update service on save for existing user', async () => {
// GIVEN
axiosStub.put.resolves({
headers: {
'x-smartbookingapp-alert': '',
'x-smartbookingapp-params': '',
},
});
axiosStub.get.withArgs(`api/admin/users/${123}`).resolves({
data: { id: 123, authorities: [] },
});
axiosStub.get.withArgs('api/authorities').resolves({ data: [] });
route = {
params: {
userId: `${123}`,
},
};
const wrapper = shallowMount(UserManagementEdit, { global: mountOptions });
const userManagementEdit: UserManagementEditComponentType = wrapper.vm;
await userManagementEdit.$nextTick();
// WHEN
userManagementEdit.save();
await userManagementEdit.$nextTick();
// THEN
expect(axiosStub.put.calledWith('api/admin/users', { id: 123, authorities: [] })).toBeTruthy();
expect(userManagementEdit.isSaving).toEqual(false);
});
it('Should call create service on save for new user', async () => {
// GIVEN
axiosStub.post.resolves({
headers: {
'x-smartbookingapp-alert': '',
'x-smartbookingapp-params': '',
},
});
axiosStub.get.resolves({});
axiosStub.get.withArgs('api/authorities').resolves({ data: [] });
route = {
params: {},
};
const wrapper = shallowMount(UserManagementEdit, { global: mountOptions });
const userManagementEdit: UserManagementEditComponentType = wrapper.vm;
await userManagementEdit.$nextTick();
userManagementEdit.userAccount = { authorities: [] };
// WHEN
userManagementEdit.save();
await userManagementEdit.$nextTick();
// THEN
expect(
axiosStub.post.calledWith('api/admin/users', {
authorities: [],
}),
).toBeTruthy();
expect(userManagementEdit.isSaving).toEqual(false);
});
});
});

View File

@@ -0,0 +1,125 @@
import { type Ref, defineComponent, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useVuelidate } from '@vuelidate/core';
import { email, maxLength, minLength, required } from '@vuelidate/validators';
import { useAlertService } from '@/shared/alert/alert.service';
import languages from '@/shared/config/languages';
import { type IUser, User } from '@/shared/model/user.model';
import UserManagementService from './user-management.service';
const loginValidator = (value: string) => {
if (!value) {
return true;
}
return /^[a-zA-Z0-9!$&*+=?^_`{|}~.-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*$|^[_.@A-Za-z0-9-]+$/.test(value);
};
const validations: any = {
userAccount: {
login: {
required,
maxLength: maxLength(254),
pattern: loginValidator,
},
firstName: {
maxLength: maxLength(50),
},
lastName: {
maxLength: maxLength(50),
},
email: {
required,
email,
minLength: minLength(5),
maxLength: maxLength(50),
},
},
};
export default defineComponent({
name: 'JhiUserManagementEdit',
validations,
setup() {
const route = useRoute();
const router = useRouter();
const alertService = inject('alertService', () => useAlertService(), true);
const userManagementService = inject('userManagementService', () => new UserManagementService(), true);
const previousState = () => router.go(-1);
const userAccount: Ref<IUser> = ref({ ...new User(), authorities: [] });
const isSaving: Ref<boolean> = ref(false);
const authorities: Ref<string[]> = ref([]);
const initAuthorities = async () => {
const response = await userManagementService.retrieveAuthorities();
authorities.value = response.data;
};
const loadUser = async (userId: string) => {
const response = await userManagementService.get(userId);
userAccount.value = response.data;
};
initAuthorities();
const userId = route.params?.userId;
if (userId) {
loadUser(userId);
}
return {
alertService,
userAccount,
isSaving,
authorities,
userManagementService,
previousState,
v$: useVuelidate(),
languages: languages(),
t$: useI18n().t,
};
},
methods: {
save(): void {
this.isSaving = true;
if (this.userAccount.id) {
this.userManagementService
.update(this.userAccount)
.then(res => {
this.returnToList();
this.alertService.showInfo(this.getToastMessageFromHeader(res));
})
.catch(error => {
this.isSaving = true;
this.alertService.showHttpError(error.response);
});
} else {
this.userManagementService
.create(this.userAccount)
.then(res => {
this.returnToList();
this.alertService.showSuccess(this.getToastMessageFromHeader(res));
})
.catch(error => {
this.isSaving = true;
this.alertService.showHttpError(error.response);
});
}
},
returnToList(): void {
this.isSaving = false;
this.previousState();
},
getToastMessageFromHeader(res: any): string {
return this.t$(res.headers['x-smartbookingapp-alert'], {
param: decodeURIComponent(res.headers['x-smartbookingapp-params'].replace(/\+/g, ' ')),
}).toString();
},
},
});

View File

@@ -0,0 +1,138 @@
<template>
<div class="d-flex justify-content-center">
<div class="col-8">
<form name="editForm" novalidate @submit.prevent="save()" v-if="userAccount">
<h2 id="myUserLabel">{{ t$('userManagement.home.createOrEditLabel') }}</h2>
<div>
<div class="mb-3" :hidden="!userAccount.id">
<label>{{ t$('global.field.id') }}</label>
<input type="text" class="form-control" name="id" v-model="userAccount.id" readonly />
</div>
<div class="mb-3">
<label class="form-control-label">{{ t$('userManagement.login') }}</label>
<input
type="text"
class="form-control"
name="login"
:class="{ 'is-valid': !v$.userAccount.login.$invalid, 'is-invalid': v$.userAccount.login.$invalid }"
v-model="v$.userAccount.login.$model"
/>
<div v-if="v$.userAccount.login.$anyDirty && v$.userAccount.login.$invalid">
<small class="form-text text-danger" v-if="v$.userAccount.login.required.$invalid">{{
t$('entity.validation.required')
}}</small>
<small class="form-text text-danger" v-if="v$.userAccount.login.maxLength.$invalid">{{
t$('entity.validation.maxlength', { max: 50 })
}}</small>
<small class="form-text text-danger" v-if="v$.userAccount.login.pattern.$invalid">{{
t$('entity.validation.patternLogin')
}}</small>
</div>
</div>
<div class="mb-3">
<label class="form-control-label" for="firstName">{{ t$('userManagement.firstName') }}</label>
<input
type="text"
class="form-control"
id="firstName"
name="firstName"
:placeholder="t$('settings.form[\'firstname.placeholder\']')"
:class="{ 'is-valid': !v$.userAccount.firstName.$invalid, 'is-invalid': v$.userAccount.firstName.$invalid }"
v-model="v$.userAccount.firstName.$model"
/>
<div v-if="v$.userAccount.firstName.$anyDirty && v$.userAccount.firstName.$invalid">
<small class="form-text text-danger" v-if="v$.userAccount.firstName.maxLength.$invalid">{{
t$('entity.validation.maxlength', { max: 50 })
}}</small>
</div>
</div>
<div class="mb-3">
<label class="form-control-label" for="lastName">{{ t$('userManagement.lastName') }}</label>
<input
type="text"
class="form-control"
id="lastName"
name="lastName"
:placeholder="t$('settings.form[\'lastname.placeholder\']')"
:class="{ 'is-valid': !v$.userAccount.lastName.$invalid, 'is-invalid': v$.userAccount.lastName.$invalid }"
v-model="v$.userAccount.lastName.$model"
/>
<div v-if="v$.userAccount.lastName.$anyDirty && v$.userAccount.lastName.$invalid">
<small class="form-text text-danger" v-if="v$.userAccount.lastName.maxLength.$invalid">{{
t$('entity.validation.maxlength', { max: 50 })
}}</small>
</div>
</div>
<div class="mb-3">
<label class="form-control-label" for="email">{{ t$('userManagement.email') }}</label>
<input
type="email"
class="form-control"
id="email"
name="email"
:placeholder="t$('global.form[\'email.placeholder\']')"
:class="{ 'is-valid': !v$.userAccount.email.$invalid, 'is-invalid': v$.userAccount.email.$invalid }"
v-model="v$.userAccount.email.$model"
email
required
/>
<div v-if="v$.userAccount.email.$anyDirty && v$.userAccount.email.$invalid">
<small class="form-text text-danger" v-if="v$.userAccount.email.required.$invalid">{{
t$('global.messages.validate.email.required')
}}</small>
<small class="form-text text-danger" v-if="v$.userAccount.email.email.$invalid">{{
t$('global.messages.validate.email.invalid')
}}</small>
<small class="form-text text-danger" v-if="v$.userAccount.email.minLength.$invalid">{{
t$('global.messages.validate.email.minlength')
}}</small>
<small class="form-text text-danger" v-if="v$.userAccount.email.maxLength.$invalid">{{
t$('global.messages.validate.email.maxlength')
}}</small>
</div>
</div>
<div class="form-check">
<label class="form-check-label" for="activated">
<input
class="form-check-input"
:disabled="userAccount.id === null"
type="checkbox"
id="activated"
name="activated"
v-model="userAccount.activated"
/>
<span>{{ t$('userManagement.activated') }}</span>
</label>
</div>
<div class="mb-3" v-if="languages && Object.keys(languages).length > 0">
<label for="langKey">{{ t$('userManagement.langKey') }}</label>
<select class="form-control" id="langKey" name="langKey" v-model="userAccount.langKey">
<option v-for="(language, key) in languages" :value="key" :key="key">{{ language.name }}</option>
</select>
</div>
<div class="mb-3">
<label>{{ t$('userManagement.profiles') }}</label>
<select class="form-control" multiple name="authority" v-model="userAccount.authorities">
<option v-for="authority of authorities" :value="authority" :key="authority">{{ authority }}</option>
</select>
</div>
</div>
<div>
<button type="button" class="btn btn-secondary" @click="previousState()">
<font-awesome-icon icon="ban"></font-awesome-icon>&nbsp;<span>{{ t$('entity.action.cancel') }}</span>
</button>
<button type="submit" :disabled="v$.userAccount.$invalid || isSaving" class="btn btn-primary">
<font-awesome-icon icon="save"></font-awesome-icon>&nbsp;<span>{{ t$('entity.action.save') }}</span>
</button>
</div>
</form>
</div>
</div>
</template>
<script lang="ts" src="./user-management-edit.component.ts"></script>

View File

@@ -0,0 +1,84 @@
import { vitest } from 'vitest';
import { type RouteLocation } from 'vue-router';
import { shallowMount } from '@vue/test-utils';
import axios from 'axios';
import sinon from 'sinon';
import AlertService from '@/shared/alert/alert.service';
import { Authority } from '@/shared/security/authority';
import UserManagementView from './user-management-view.vue';
let route: Partial<RouteLocation>;
vitest.mock('vue-router', () => ({
useRoute: () => route,
}));
const axiosStub = {
get: sinon.stub(axios, 'get'),
};
describe('UserManagementView Component', () => {
let alertService: AlertService;
beforeEach(() => {
route = {};
alertService = new AlertService({
i18n: { t: vitest.fn() } as any,
toast: {
show: vitest.fn(),
} as any,
});
});
describe('OnInit', () => {
it('Should call load all on init', async () => {
// GIVEN
const userData = {
id: 1,
login: 'user',
firstName: 'first',
lastName: 'last',
email: 'first@last.com',
activated: true,
langKey: 'en',
authorities: [Authority.USER],
createdBy: 'admin',
createdDate: null,
lastModifiedBy: null,
lastModifiedDate: null,
password: null,
};
axiosStub.get.resolves({ data: userData });
route = {
params: {
userId: `${123}`,
},
};
const wrapper = shallowMount(UserManagementView, {
global: {
stubs: {
'b-badge': true,
'router-link': true,
'font-awesome-icon': true,
},
provide: {
alertService,
},
},
});
const userManagementView = wrapper.vm;
// WHEN
await userManagementView.$nextTick();
// THEN
expect(axiosStub.get.calledWith(`api/admin/users/${123}`)).toBeTruthy();
expect(userManagementView.user).toEqual(userData);
});
});
});

View File

@@ -0,0 +1,40 @@
import { type Ref, defineComponent, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useAlertService } from '@/shared/alert/alert.service';
import { useDateFormat } from '@/shared/composables';
import UserManagementService from './user-management.service';
export default defineComponent({
name: 'JhiUserManagementView',
setup() {
const route = useRoute();
const { formatDateLong: formatDate } = useDateFormat();
const alertService = inject('alertService', () => useAlertService(), true);
const userManagementService = inject('userManagementService', () => new UserManagementService(), true);
const user: Ref<any> = ref(null);
async function loadUser(userId: string) {
try {
const response = await userManagementService.get(userId);
user.value = response.data;
} catch (error) {
alertService.showHttpError(error.response);
}
}
loadUser(route.params?.userId);
return {
formatDate,
alertService,
userManagementService,
user,
t$: useI18n().t,
};
},
});

View File

@@ -0,0 +1,80 @@
<template>
<div class="d-flex justify-content-center">
<div class="col-8">
<div v-if="user">
<h2 class="jh-entity-heading">
<span>{{ t$('userManagement.detail.title') }}</span> [<strong>{{ user.login }}</strong
>]
</h2>
<dl class="row-md jh-entity-details">
<dt>
<span>{{ t$('userManagement.login') }}</span>
</dt>
<dd>
<span>{{ user.login }}</span>
<b-badge style="margin-left: 10px" :variant="user.activated ? 'success' : 'danger'">{{
t$(user.activated ? 'userManagement.activated' : 'userManagement.deactivated')
}}</b-badge>
</dd>
<dt>
<span>{{ t$('userManagement.firstName') }}</span>
</dt>
<dd>{{ user.firstName }}</dd>
<dt>
<span>{{ t$('userManagement.lastName') }}</span>
</dt>
<dd>{{ user.lastName }}</dd>
<dt>
<span>{{ t$('userManagement.email') }}</span>
</dt>
<dd>{{ user.email }}</dd>
<dt>
<span>{{ t$('userManagement.langKey') }}</span>
</dt>
<dd>{{ user.langKey }}</dd>
<dt>
<span>{{ t$('userManagement.createdBy') }}</span>
</dt>
<dd>{{ user.createdBy }}</dd>
<dt>
<span>{{ t$('userManagement.createdDate') }}</span>
</dt>
<dd>
<span v-if="user.createdDate">
{{ formatDate(user.createdDate) }}
</span>
</dd>
<dt>
<span>{{ t$('userManagement.lastModifiedBy') }}</span>
</dt>
<dd>{{ user.lastModifiedBy }}</dd>
<dt>
<span>{{ t$('userManagement.lastModifiedDate') }}</span>
</dt>
<dd>
<span v-if="user.lastModifiedDate">
{{ formatDate(user.lastModifiedDate) }}
</span>
</dd>
<dt>
<span>{{ t$('userManagement.profiles') }}</span>
</dt>
<dd>
<ul class="list-unstyled">
<li v-for="authority of user.authorities" :key="authority">
<b-badge variant="info">{{ authority }}</b-badge>
</li>
</ul>
</dd>
</dl>
<router-link custom v-slot="{ navigate }" :to="{ name: 'JhiUser' }">
<button @click="navigate" class="btn btn-info">
<font-awesome-icon icon="arrow-left"></font-awesome-icon>&nbsp;<span>{{ t$('entity.action.back') }}</span>
</button>
</router-link>
</div>
</div>
</div>
</template>
<script lang="ts" src="./user-management-view.component.ts"></script>

View File

@@ -0,0 +1,116 @@
import { vitest } from 'vitest';
import { ref } from 'vue';
import { shallowMount } from '@vue/test-utils';
import axios from 'axios';
import sinon from 'sinon';
import AlertService from '@/shared/alert/alert.service';
import UserManagement from './user-management.vue';
type UserManagementComponentType = InstanceType<typeof UserManagement>;
const axiosStub = {
delete: sinon.stub(axios, 'delete'),
get: sinon.stub(axios, 'get'),
put: sinon.stub(axios, 'put'),
};
describe('UserManagement Component', () => {
let userManagement: UserManagementComponentType;
let alertService: AlertService;
beforeEach(() => {
axiosStub.put.reset();
axiosStub.get.reset();
axiosStub.get.resolves({ headers: {} });
alertService = new AlertService({
i18n: { t: vitest.fn() } as any,
toast: {
show: vitest.fn(),
} as any,
});
const wrapper = shallowMount(UserManagement, {
global: {
stubs: {
bPagination: true,
jhiItemCount: true,
bModal: true,
'router-link': true,
'jhi-sort-indicator': true,
'font-awesome-icon': true,
'b-button': true,
},
directives: {
'b-modal': {},
},
provide: {
alertService,
currentUsername: ref(''),
},
},
});
userManagement = wrapper.vm;
});
describe('OnInit', () => {
it('Should call load all on init', async () => {
// WHEN
userManagement.loadAll();
await userManagement.$nextTick();
// THEN
expect(axiosStub.get.calledWith(`api/admin/users?sort=id,asc&page=0&size=20`)).toBeTruthy();
});
});
describe('setActive', () => {
it('Should update user and call load all', async () => {
// GIVEN
axiosStub.put.resolves({});
// WHEN
userManagement.setActive({ id: 123 }, true);
await userManagement.$nextTick();
// THEN
expect(axiosStub.put.calledWith(`api/admin/users`, { id: 123, activated: true })).toBeTruthy();
expect(axiosStub.get.calledWith(`api/admin/users?sort=id,asc&page=0&size=20`)).toBeTruthy();
});
});
describe('confirmDelete', () => {
it('Should call delete service on confirmDelete', async () => {
// GIVEN
axiosStub.delete.resolves({
headers: {
'x-smartbookingapp-alert': '',
'x-smartbookingapp-params': '',
},
});
// WHEN
userManagement.prepareRemove({ login: 123 });
userManagement.deleteUser();
await userManagement.$nextTick();
// THEN
expect(axiosStub.delete.calledWith(`api/admin/users/${123}`)).toBeTruthy();
expect(axiosStub.get.calledWith(`api/admin/users?sort=id,asc&page=0&size=20`)).toBeTruthy();
});
});
describe('change order', () => {
it('should change order and invert reverse', () => {
// WHEN
userManagement.changeOrder('dummy-order');
// THEN
expect(userManagement.propOrder).toEqual('dummy-order');
expect(userManagement.reverse).toBe(true);
});
});
});

View File

@@ -0,0 +1,142 @@
import { type ComputedRef, type Ref, defineComponent, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlertService } from '@/shared/alert/alert.service';
import { useDateFormat } from '@/shared/composables';
import UserManagementService from './user-management.service';
export default defineComponent({
name: 'JhiUserManagementComponent',
setup() {
const alertService = inject('alertService', () => useAlertService(), true);
const { formatDateShort: formatDate } = useDateFormat();
const userManagementService = inject('userManagementService', () => new UserManagementService(), true);
const username = inject<ComputedRef<string>>('currentUsername');
const error = ref('');
const success = ref('');
const itemsPerPage = ref(20);
const page = ref(1);
const previousPage = ref(1);
const propOrder = ref('id');
const reverse = ref(false);
const isLoading = ref(false);
const removeId: Ref<number> = ref(null);
const users: Ref<any[]> = ref([]);
const totalItems = ref(0);
const queryCount: Ref<number> = ref(null);
return {
formatDate,
userManagementService,
alertService,
error,
success,
itemsPerPage,
page,
previousPage,
propOrder,
reverse,
isLoading,
removeId,
users,
username,
totalItems,
queryCount,
t$: useI18n().t,
};
},
mounted(): void {
this.loadAll();
},
methods: {
setActive(user, isActivated): void {
user.activated = isActivated;
this.userManagementService
.update(user)
.then(() => {
this.error = null;
this.success = 'OK';
this.loadAll();
})
.catch(() => {
this.success = null;
this.error = 'ERROR';
user.activated = false;
});
},
loadAll(): void {
this.isLoading = true;
this.userManagementService
.retrieve({
sort: this.sort(),
page: this.page - 1,
size: this.itemsPerPage,
})
.then(res => {
this.isLoading = false;
this.users = res.data;
this.totalItems = Number(res.headers['x-total-count']);
this.queryCount = this.totalItems;
})
.catch(() => {
this.isLoading = false;
});
},
handleSyncList(): void {
this.loadAll();
},
sort(): any {
const result = [`${this.propOrder},${this.reverse ? 'desc' : 'asc'}`];
if (this.propOrder !== 'id') {
result.push('id');
}
return result;
},
loadPage(page: number): void {
if (page !== this.previousPage) {
this.previousPage = page;
this.transition();
}
},
transition(): void {
this.loadAll();
},
changeOrder(propOrder: string): void {
this.propOrder = propOrder;
this.reverse = !this.reverse;
this.transition();
},
deleteUser(): void {
this.userManagementService
.remove(this.removeId)
.then(res => {
this.alertService.showInfo(
this.t$(res.headers['x-smartbookingapp-alert'].toString(), {
param: decodeURIComponent(res.headers['x-smartbookingapp-params'].replace(/\+/g, ' ')),
}),
{ variant: 'danger' },
);
this.removeId = null;
this.loadAll();
this.closeDialog();
})
.catch(error => {
this.alertService.showHttpError(error.response);
});
},
prepareRemove(instance): void {
this.removeId = instance.login;
if (<any>this.$refs.removeUser) {
(<any>this.$refs.removeUser).show();
}
},
closeDialog(): void {
if (<any>this.$refs.removeUser) {
(<any>this.$refs.removeUser).hide();
}
},
},
});

View File

@@ -0,0 +1,33 @@
import axios from 'axios';
import { type IUser } from '@/shared/model/user.model';
import buildPaginationQueryOpts from '@/shared/sort/sorts';
export default class UserManagementService {
get(userId: string): Promise<any> {
return axios.get(`api/admin/users/${userId}`);
}
create(user: IUser): Promise<any> {
return axios.post('api/admin/users', user);
}
update(user: IUser): Promise<any> {
return axios.put('api/admin/users', user);
}
remove(userId: number): Promise<any> {
return axios.delete(`api/admin/users/${userId}`);
}
retrieve(req?: any): Promise<any> {
return axios.get(`api/admin/users?${buildPaginationQueryOpts(req)}`);
}
retrieveAuthorities(): Promise<any> {
return axios.get('api/authorities').then(response => {
response.data = response.data.map(authority => authority.name);
return response;
});
}
}

View File

@@ -0,0 +1,134 @@
<template>
<div>
<h2>
<span id="user-management-page-heading" data-cy="userManagementPageHeading">{{ t$('userManagement.home.title') }}</span>
<div class="d-flex justify-content-end">
<button class="btn btn-info me-2" @click="handleSyncList" :disabled="isLoading">
<font-awesome-icon icon="sync" :spin="isLoading"></font-awesome-icon>
<span>{{ t$('userManagement.home.refreshListLabel') }}</span>
</button>
<router-link custom v-slot="{ navigate }" :to="{ name: 'JhiUserCreate' }">
<button @click="navigate" class="btn btn-primary jh-create-entity">
<font-awesome-icon icon="plus"></font-awesome-icon> <span>{{ t$('userManagement.home.createLabel') }}</span>
</button>
</router-link>
</div>
</h2>
<div class="table-responsive" v-if="users">
<table class="table table-striped" aria-describedby="Users">
<thead>
<tr>
<th scope="col" @click="changeOrder('id')">
<span>{{ t$('global.field.id') }}</span>
<jhi-sort-indicator :current-order="propOrder" :reverse="reverse" :field-name="'id'"></jhi-sort-indicator>
</th>
<th scope="col" @click="changeOrder('login')">
<span>{{ t$('userManagement.login') }}</span>
<jhi-sort-indicator :current-order="propOrder" :reverse="reverse" :field-name="'login'"></jhi-sort-indicator>
</th>
<th scope="col" @click="changeOrder('email')">
<span>{{ t$('userManagement.email') }}</span>
<jhi-sort-indicator :current-order="propOrder" :reverse="reverse" :field-name="'email'"></jhi-sort-indicator>
</th>
<th scope="col"></th>
<th scope="col" @click="changeOrder('langKey')">
<span>{{ t$('userManagement.langKey') }}</span>
<jhi-sort-indicator :current-order="propOrder" :reverse="reverse" :field-name="'langKey'"></jhi-sort-indicator>
</th>
<th scope="col">
<span>{{ t$('userManagement.profiles') }}</span>
</th>
<th scope="col" @click="changeOrder('createdDate')">
<span>{{ t$('userManagement.createdDate') }}</span>
<jhi-sort-indicator :current-order="propOrder" :reverse="reverse" :field-name="'createdDate'"></jhi-sort-indicator>
</th>
<th scope="col" @click="changeOrder('lastModifiedBy')">
<span>{{ t$('userManagement.lastModifiedBy') }}</span>
<jhi-sort-indicator :current-order="propOrder" :reverse="reverse" :field-name="'lastModifiedBy'"></jhi-sort-indicator>
</th>
<th scope="col" id="modified-date-sort" @click="changeOrder('lastModifiedDate')">
<span>{{ t$('userManagement.lastModifiedDate') }}</span>
<jhi-sort-indicator :current-order="propOrder" :reverse="reverse" :field-name="'lastModifiedDate'"></jhi-sort-indicator>
</th>
<th scope="col"></th>
</tr>
</thead>
<tbody v-if="users">
<tr v-for="user in users" :key="user.id" :id="user.login">
<td>
<router-link :to="{ name: 'JhiUserView', params: { userId: user.login } }">{{ user.id }}</router-link>
</td>
<td>{{ user.login }}</td>
<td class="jhi-user-email">{{ user.email }}</td>
<td>
<button class="btn btn-danger btn-sm deactivated" @click="setActive(user, true)" v-if="!user.activated">
{{ t$('userManagement.deactivated') }}
</button>
<button
class="btn btn-success btn-sm"
@click="setActive(user, false)"
v-if="user.activated"
:disabled="username === user.login"
>
{{ t$('userManagement.activated') }}
</button>
</td>
<td>{{ user.langKey }}</td>
<td>
<div v-for="authority of user.authorities" :key="authority">
<span class="badge bg-info">{{ authority }}</span>
</div>
</td>
<td>{{ formatDate(user.createdDate) }}</td>
<td>{{ user.lastModifiedBy }}</td>
<td>{{ formatDate(user.lastModifiedDate) }}</td>
<td class="text-end">
<div class="btn-group">
<router-link :to="{ name: 'JhiUserView', params: { userId: user.login } }" custom v-slot="{ navigate }">
<button @click="navigate" class="btn btn-info btn-sm details">
<font-awesome-icon icon="eye"></font-awesome-icon>
<span class="d-none d-md-inline">{{ t$('entity.action.view') }}</span>
</button>
</router-link>
<router-link :to="{ name: 'JhiUserEdit', params: { userId: user.login } }" custom v-slot="{ navigate }">
<button @click="navigate" class="btn btn-primary btn-sm edit">
<font-awesome-icon icon="pencil-alt"></font-awesome-icon>
<span class="d-none d-md-inline">{{ t$('entity.action.edit') }}</span>
</button>
</router-link>
<b-button @click="prepareRemove(user)" variant="danger" class="btn btn-sm delete" :disabled="username === user.login">
<font-awesome-icon icon="times"></font-awesome-icon>
<span class="d-none d-md-inline">{{ t$('entity.action.delete') }}</span>
</b-button>
</div>
</td>
</tr>
</tbody>
</table>
<b-modal ref="removeUser" id="removeUser" :title="t$('entity.delete.title')" @ok="deleteUser()">
<div class="modal-body">
<p id="jhi-delete-user-heading">{{ t$('userManagement.delete.question', { login: removeId }) }}</p>
</div>
<template #footer>
<div>
<button type="button" class="btn btn-secondary" @click="closeDialog()">{{ t$('entity.action.cancel') }}</button>
<button type="button" class="btn btn-primary" id="confirm-delete-user" @click="deleteUser()">
{{ t$('entity.action.delete') }}
</button>
</div>
</template>
</b-modal>
</div>
<div v-show="users?.length > 0">
<div class="d-flex justify-content-center">
<jhi-item-count :page="page" :total="queryCount" :items-per-page="itemsPerPage"></jhi-item-count>
</div>
<div class="d-flex justify-content-center">
<b-pagination size="md" :total-rows="totalItems" v-model="page" :per-page="itemsPerPage" :change="loadPage(page)"></b-pagination>
</div>
</div>
</div>
</template>
<script lang="ts" src="./user-management.component.ts"></script>

View File

@@ -0,0 +1,33 @@
import { defineComponent, provide } from 'vue';
import { useI18n } from 'vue-i18n';
import { BToastOrchestrator } from 'bootstrap-vue-next';
import { storeToRefs } from 'pinia';
import LoginForm from '@/account/login-form/login-form.vue';
import { useLoginModal } from '@/account/login-modal';
import JhiFooter from '@/core/jhi-footer/jhi-footer.vue';
import JhiNavbar from '@/core/jhi-navbar/jhi-navbar.vue';
import Ribbon from '@/core/ribbon/ribbon.vue';
import { useAlertService } from '@/shared/alert/alert.service';
import '@/shared/config/dayjs';
export default defineComponent({
name: 'App',
components: {
BToastOrchestrator,
Ribbon,
JhiNavbar,
LoginForm,
JhiFooter,
},
setup() {
provide('alertService', useAlertService());
const { loginModalOpen } = storeToRefs(useLoginModal());
return {
loginModalOpen,
t$: useI18n().t,
};
},
});

View File

@@ -0,0 +1,23 @@
<template>
<BToastOrchestrator />
<div id="app">
<ribbon></ribbon>
<div id="app-header">
<jhi-navbar></jhi-navbar>
</div>
<div class="container-fluid">
<div class="card jh-card">
<router-view></router-view>
</div>
<b-modal id="login-page" focus="username" v-model="loginModalOpen" :no-footer="true" lazy>
<template #title>
<span data-cy="loginTitle" id="login-title">{{ t$('login.title') }}</span>
</template>
<login-form v-if="loginModalOpen"></login-form>
</b-modal>
<jhi-footer></jhi-footer>
</div>
</div>
</template>
<script lang="ts" src="./app.component.ts"></script>

View File

@@ -0,0 +1,4 @@
// Errors
export const PROBLEM_BASE_URL = 'https://www.jhipster.tech/problem';
export const EMAIL_ALREADY_USED_TYPE = `${PROBLEM_BASE_URL}/email-already-used`;
export const LOGIN_ALREADY_USED_TYPE = `${PROBLEM_BASE_URL}/login-already-used`;

View File

@@ -0,0 +1,95 @@
import { vitest } from 'vitest';
import { type Ref, ref } from 'vue';
import { type RouteLocation } from 'vue-router';
import { createTestingPinia } from '@pinia/testing';
import { type ComponentMountingOptions, shallowMount } from '@vue/test-utils';
import { useLoginModal } from '@/account/login-modal';
import Error from './error.vue';
type ErrorComponentType = InstanceType<typeof Error>;
let route: Partial<RouteLocation>;
vitest.mock('vue-router', () => ({
useRoute: () => route,
}));
const customErrorMsg = 'An error occurred.';
describe('Error component', () => {
let error: ErrorComponentType;
let login: ReturnType<typeof useLoginModal>;
let authenticated: Ref<boolean>;
let mountOptions: ComponentMountingOptions<ErrorComponentType>;
beforeEach(() => {
route = {};
authenticated = ref(false);
mountOptions = {
global: {
plugins: [createTestingPinia()],
provide: {
authenticated,
},
},
};
});
it('should have retrieve custom error on routing', () => {
route = {
path: '/custom-error',
name: 'CustomMessage',
meta: { errorMessage: customErrorMsg },
};
const wrapper = shallowMount(Error, mountOptions);
error = wrapper.vm;
login = useLoginModal();
expect(error.errorMessage).toBe(customErrorMsg);
expect(error.error403).toBeFalsy();
expect(error.error404).toBeFalsy();
expect(login.showLogin).not.toHaveBeenCalled();
});
it('should have set forbidden error on routing', () => {
route = {
meta: { error403: true },
};
const wrapper = shallowMount(Error, mountOptions);
error = wrapper.vm;
login = useLoginModal();
expect(error.errorMessage).toBeNull();
expect(error.error403).toBeTruthy();
expect(error.error404).toBeFalsy();
expect(login.showLogin).toHaveBeenCalled();
});
it('should have set not found error on routing', () => {
route = {
meta: { error404: true },
};
const wrapper = shallowMount(Error, mountOptions);
error = wrapper.vm;
login = useLoginModal();
expect(error.errorMessage).toBeNull();
expect(error.error403).toBeFalsy();
expect(error.error404).toBeTruthy();
expect(login.showLogin).not.toHaveBeenCalled();
});
it('should have set default on no error', () => {
const wrapper = shallowMount(Error, mountOptions);
error = wrapper.vm;
login = useLoginModal();
expect(error.errorMessage).toBeNull();
expect(error.error403).toBeFalsy();
expect(error.error404).toBeFalsy();
expect(login.showLogin).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,33 @@
import { type ComputedRef, type Ref, defineComponent, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useLoginModal } from '@/account/login-modal';
export default defineComponent({
name: 'Error',
setup() {
const { showLogin } = useLoginModal();
const authenticated = inject<ComputedRef<boolean>>('authenticated');
const errorMessage: Ref<string> = ref(null);
const error403: Ref<boolean> = ref(false);
const error404: Ref<boolean> = ref(false);
const route = useRoute();
if (route.meta) {
errorMessage.value = route.meta.errorMessage ?? null;
error403.value = route.meta.error403 ?? false;
error404.value = route.meta.error404 ?? false;
if (!authenticated.value && error403.value) {
showLogin();
}
}
return {
errorMessage,
error403,
error404,
t$: useI18n().t,
};
},
});

View File

@@ -0,0 +1,20 @@
<template>
<div>
<div class="row-md">
<div class="col-md-3">
<span class="hipster img-fluid rounded"></span>
</div>
<div class="col-md-9">
<h1>{{ t$('error.title') }}</h1>
<div v-if="errorMessage">
<div class="alert alert-danger">{{ errorMessage }}</div>
</div>
<div v-if="error403" class="alert alert-danger">{{ t$('error.http.403') }}</div>
<div v-if="error404" class="alert alert-warning">{{ t$('error.http.404') }}</div>
</div>
</div>
</div>
</template>
<script lang="ts" src="./error.component.ts"></script>

View File

@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { ref } from 'vue';
import { createTestingPinia } from '@pinia/testing';
import { shallowMount } from '@vue/test-utils';
import { useLoginModal } from '@/account/login-modal';
import Home from './home.vue';
type HomeComponentType = InstanceType<typeof Home>;
describe('Home', () => {
let home: HomeComponentType;
let authenticated;
let currentUsername;
let login: ReturnType<typeof useLoginModal>;
beforeEach(() => {
authenticated = ref(false);
currentUsername = ref('');
const wrapper = shallowMount(Home, {
global: {
plugins: [createTestingPinia()],
stubs: {
'router-link': true,
},
provide: {
authenticated,
currentUsername,
},
},
});
home = wrapper.vm;
login = useLoginModal();
});
it('should not have user data set', () => {
expect(home.authenticated).toBeFalsy();
expect(home.username).toBe('');
});
it('should have user data set after authentication', () => {
authenticated.value = true;
currentUsername.value = 'test';
expect(home.authenticated).toBeTruthy();
expect(home.username).toBe('test');
});
it('should use login service', () => {
home.showLogin();
expect(login.showLogin).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,19 @@
import { type ComputedRef, defineComponent, inject } from 'vue';
import { useI18n } from 'vue-i18n';
import { useLoginModal } from '@/account/login-modal';
export default defineComponent({
setup() {
const { showLogin } = useLoginModal();
const authenticated = inject<ComputedRef<boolean>>('authenticated');
const username = inject<ComputedRef<string>>('currentUsername');
return {
authenticated,
username,
showLogin,
t$: useI18n().t,
};
},
});

View File

@@ -0,0 +1,60 @@
<template>
<div class="home row">
<div class="col-md-3">
<span class="hipster img-fluid rounded"></span>
</div>
<div class="col-md-9">
<h1 class="display-4">{{ t$('home.title') }}</h1>
<p class="lead">{{ t$('home.subtitle') }}</p>
<div>
<div class="alert alert-success" v-if="authenticated">
<span v-if="username">{{ t$('home.logged.message', { username }) }}</span>
</div>
<div class="alert alert-warning" v-if="!authenticated">
<span>{{ t$('global.messages.info.authenticated.prefix') }}</span>
<a class="alert-link" @click="showLogin()">{{ t$('global.messages.info.authenticated.link') }}</a
><span v-html="t$('global.messages.info.authenticated.suffix')"></span>
</div>
<div class="alert alert-warning" v-if="!authenticated">
<span>{{ t$('global.messages.info.register.noaccount') }}</span
>&nbsp;
<router-link class="alert-link" to="/register">{{ t$('global.messages.info.register.link') }}</router-link>
</div>
</div>
<p>{{ t$('home.question') }}</p>
<ul>
<li>
<a href="https://www.jhipster.tech/" target="_blank" rel="noopener noreferrer">{{ t$('home.link.homepage') }}</a>
</li>
<li>
<a href="https://stackoverflow.com/tags/jhipster/info" target="_blank" rel="noopener noreferrer">{{
t$('home.link.stackoverflow')
}}</a>
</li>
<li>
<a href="https://github.com/jhipster/generator-jhipster/issues?state=open" target="_blank" rel="noopener noreferrer">{{
t$('home.link.bugtracker')
}}</a>
</li>
<li>
<a href="https://gitter.im/jhipster/generator-jhipster" target="_blank" rel="noopener noreferrer">{{ t$('home.link.chat') }}</a>
</li>
<li>
<a href="https://twitter.com/jhipster" target="_blank" rel="noopener noreferrer">{{ t$('home.link.follow') }}</a>
</li>
</ul>
<p>
<span>{{ t$('home.like') }}</span>
<a href="https://github.com/jhipster/generator-jhipster" target="_blank" rel="noopener noreferrer">{{ t$('home.github') }}</a
>!
</p>
</div>
</div>
</template>
<script lang="ts" src="./home.component.ts"></script>

View File

@@ -0,0 +1,11 @@
import { defineComponent } from 'vue';
import { useI18n } from 'vue-i18n';
export default defineComponent({
name: 'JhiFooter',
setup() {
return {
t$: useI18n().t,
};
},
});

View File

@@ -0,0 +1,7 @@
<template>
<div id="footer" class="footer">
<p>{{ t$('footer') }}</p>
</div>
</template>
<script lang="ts" src="./jhi-footer.component.ts"></script>

View File

@@ -0,0 +1,115 @@
import { vitest } from 'vitest';
import { computed } from 'vue';
import { type Router } from 'vue-router';
import { createTestingPinia } from '@pinia/testing';
import { shallowMount } from '@vue/test-utils';
import { useLoginModal } from '@/account/login-modal';
import type LoginService from '@/account/login.service';
import { createRouter } from '@/router';
import { useStore } from '@/store';
import JhiNavbar from './jhi-navbar.vue';
type JhiNavbarComponentType = InstanceType<typeof JhiNavbar>;
const pinia = createTestingPinia({ stubActions: false });
const store = useStore();
describe('JhiNavbar', () => {
let jhiNavbar: JhiNavbarComponentType;
let loginService: LoginService;
let login: ReturnType<typeof useLoginModal>;
const accountService = { hasAnyAuthorityAndCheckAuth: vitest.fn().mockImplementation(() => Promise.resolve(true)) };
const changeLanguage = vitest.fn();
let router: Router;
beforeEach(() => {
router = createRouter();
loginService = { login: vitest.fn(), logout: vitest.fn() };
const wrapper = shallowMount(JhiNavbar, {
global: {
plugins: [pinia, router],
stubs: {
'font-awesome-icon': true,
'b-navbar': true,
'b-navbar-nav': true,
'b-dropdown-item': true,
'b-collapse': true,
'b-nav-item': true,
'b-nav-item-dropdown': true,
'b-navbar-toggle': true,
'b-navbar-brand': true,
},
provide: {
loginService,
currentLanguage: computed(() => 'foo'),
changeLanguage,
accountService,
},
},
});
jhiNavbar = wrapper.vm;
login = useLoginModal();
});
it('should not have user data set', () => {
expect(jhiNavbar.authenticated).toBeFalsy();
expect(jhiNavbar.openAPIEnabled).toBeFalsy();
expect(jhiNavbar.inProduction).toBeFalsy();
});
it('should have user data set after authentication', () => {
store.setAuthentication({ login: 'test' });
expect(jhiNavbar.authenticated).toBeTruthy();
});
it('should have profile info set after info retrieved', () => {
store.setActiveProfiles(['prod', 'api-docs']);
expect(jhiNavbar.openAPIEnabled).toBeTruthy();
expect(jhiNavbar.inProduction).toBeTruthy();
});
it('should use login service', () => {
jhiNavbar.showLogin();
expect(login.showLogin).toHaveBeenCalled();
});
it('should use account service', () => {
jhiNavbar.hasAnyAuthority('auth');
expect(accountService.hasAnyAuthorityAndCheckAuth).toHaveBeenCalled();
});
it('logout should clear credentials', async () => {
store.setAuthentication({ login: 'test' });
(loginService.logout as any).mockReturnValue(Promise.resolve({}));
await jhiNavbar.logout();
expect(loginService.logout).toHaveBeenCalled();
});
it('should determine active route', async () => {
await router.push('/forbidden');
expect(jhiNavbar.subIsActive('/titi')).toBeFalsy();
expect(jhiNavbar.subIsActive('/forbidden')).toBeTruthy();
expect(jhiNavbar.subIsActive(['/forbidden', 'forbidden'])).toBeTruthy();
});
it('should call translationService when changing language', () => {
jhiNavbar.changeLanguage('fr');
expect(changeLanguage).toHaveBeenCalled();
});
it('should check for correct language', () => {
expect(jhiNavbar.isActiveLanguage('en')).toBeFalsy();
expect(jhiNavbar.isActiveLanguage('foo')).toBeTruthy();
});
});

View File

@@ -0,0 +1,81 @@
import { type Ref, computed, defineComponent, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import type AccountService from '@/account/account.service';
import { useLoginModal } from '@/account/login-modal';
import type LoginService from '@/account/login.service';
import EntitiesMenu from '@/entities/entities-menu.vue';
import languages from '@/shared/config/languages';
import { useStore } from '@/store';
export default defineComponent({
name: 'JhiNavbar',
components: {
'entities-menu': EntitiesMenu,
},
setup() {
const loginService = inject<LoginService>('loginService');
const { showLogin } = useLoginModal();
const accountService = inject<AccountService>('accountService');
const currentLanguage = inject('currentLanguage', () => computed(() => navigator.language ?? 'it'), true);
const changeLanguage = inject<(string) => Promise<void>>('changeLanguage');
const isActiveLanguage = (key: string) => {
return key === currentLanguage.value;
};
const router = useRouter();
const store = useStore();
const version = `v${APP_VERSION}`;
const hasAnyAuthorityValues: Ref<any> = ref({});
const openAPIEnabled = computed(() => store.activeProfiles.indexOf('api-docs') > -1);
const inProduction = computed(() => store.activeProfiles.indexOf('prod') > -1);
const authenticated = computed(() => store.authenticated);
const subIsActive = (input: string | string[]) => {
const paths = Array.isArray(input) ? input : [input];
return paths.some(path => {
return router.currentRoute.value.path.startsWith(path); // current path starts with this path string
});
};
const logout = async () => {
const response = await loginService.logout();
store.logout();
if (router.currentRoute.value.path !== '/') {
await router.push('/');
}
};
return {
logout,
subIsActive,
accountService,
showLogin,
changeLanguage,
languages: languages(),
isActiveLanguage,
version,
currentLanguage,
hasAnyAuthorityValues,
openAPIEnabled,
inProduction,
authenticated,
t$: useI18n().t,
};
},
methods: {
hasAnyAuthority(authorities: any): boolean {
this.accountService.hasAnyAuthorityAndCheckAuth(authorities).then(value => {
if (this.hasAnyAuthorityValues[authorities] !== value) {
this.hasAnyAuthorityValues = { ...this.hasAnyAuthorityValues, [authorities]: value };
}
});
return this.hasAnyAuthorityValues[authorities] ?? false;
},
},
});

View File

@@ -0,0 +1,199 @@
<template>
<b-navbar data-cy="navbar" toggleable="md" variant="dark" data-bs-theme="dark">
<b-navbar-brand class="logo" b-link to="/">
<span class="logo-img"></span>
<span class="navbar-title">{{ t$('global.title') }}</span> <span class="navbar-version">{{ version }}</span>
</b-navbar-brand>
<b-navbar-toggle
right
class="jh-navbar-toggler d-lg-none"
href="javascript:void(0);"
data-toggle="collapse"
target="header-tabs"
aria-expanded="false"
aria-label="Toggle navigation"
>
<font-awesome-icon icon="bars" />
</b-navbar-toggle>
<b-collapse is-nav id="header-tabs">
<b-navbar-nav class="ms-auto">
<b-nav-item to="/" exact>
<span>
<font-awesome-icon icon="fa-solid fa-home" />
<span>{{ t$('global.menu.home') }}</span>
</span>
</b-nav-item>
<b-nav-item-dropdown
no-size="true"
end
id="entity-menu"
v-if="authenticated"
active-class="active"
class="pointer"
data-cy="entity"
>
<template #button-content>
<span class="navbar-dropdown-menu">
<font-awesome-icon icon="th-list" />
<span class="no-bold">{{ t$('global.menu.entities.main') }}</span>
</span>
</template>
<entities-menu></entities-menu>
<!-- jhipster-needle-add-entity-to-menu - JHipster will add entities to the menu here -->
</b-nav-item-dropdown>
<b-nav-item-dropdown
right
id="admin-menu"
v-if="hasAnyAuthority('ROLE_ADMIN') && authenticated"
:class="{ 'router-link-active': subIsActive('/admin') }"
active-class="active"
class="pointer"
data-cy="adminMenu"
>
<template #button-content>
<span class="navbar-dropdown-menu">
<font-awesome-icon icon="users-cog" />
<span class="no-bold">{{ t$('global.menu.admin.main') }}</span>
</span>
</template>
<b-dropdown-item to="/admin/user-management" active-class="active">
<font-awesome-icon icon="users" />
<span>{{ t$('global.menu.admin.userManagement') }}</span>
</b-dropdown-item>
<b-dropdown-item to="/admin/metrics" active-class="active">
<font-awesome-icon icon="tachometer-alt" />
<span>{{ t$('global.menu.admin.metrics') }}</span>
</b-dropdown-item>
<b-dropdown-item to="/admin/health" active-class="active">
<font-awesome-icon icon="heart" />
<span>{{ t$('global.menu.admin.health') }}</span>
</b-dropdown-item>
<b-dropdown-item to="/admin/configuration" active-class="active">
<font-awesome-icon icon="cogs" />
<span>{{ t$('global.menu.admin.configuration') }}</span>
</b-dropdown-item>
<b-dropdown-item to="/admin/logs" active-class="active">
<font-awesome-icon icon="tasks" />
<span>{{ t$('global.menu.admin.logs') }}</span>
</b-dropdown-item>
<b-dropdown-item v-if="openAPIEnabled" to="/admin/docs" active-class="active">
<font-awesome-icon icon="book" />
<span>{{ t$('global.menu.admin.apidocs') }}</span>
</b-dropdown-item>
</b-nav-item-dropdown>
<b-nav-item-dropdown id="languagesnavBarDropdown" end v-if="languages && Object.keys(languages).length > 1">
<template #button-content>
<font-awesome-icon icon="flag" />
<span class="no-bold">{{ t$('global.menu.language') }}</span>
</template>
<b-dropdown-item
v-for="(value, key) in languages"
:key="`lang-${key}`"
@click="changeLanguage(key)"
:class="{ active: isActiveLanguage(key) }"
>
{{ value.name }}
</b-dropdown-item>
</b-nav-item-dropdown>
<b-nav-item-dropdown
right
href="javascript:void(0);"
id="account-menu"
:class="{ 'router-link-active': subIsActive('/account') }"
active-class="active"
class="pointer"
data-cy="accountMenu"
>
<template #button-content>
<span class="navbar-dropdown-menu">
<font-awesome-icon icon="user" />
<span class="no-bold">{{ t$('global.menu.account.main') }}</span>
</span>
</template>
<b-dropdown-item data-cy="settings" to="/account/settings" v-if="authenticated" active-class="active">
<font-awesome-icon icon="wrench" />
<span>{{ t$('global.menu.account.settings') }}</span>
</b-dropdown-item>
<b-dropdown-item data-cy="passwordItem" to="/account/password" v-if="authenticated" active-class="active">
<font-awesome-icon icon="lock" />
<span>{{ t$('global.menu.account.password') }}</span>
</b-dropdown-item>
<b-dropdown-item to="/account/sessions" v-if="authenticated" active-class="active">
<font-awesome-icon icon="cloud" />
<span>{{ t$('global.menu.account.sessions') }}</span>
</b-dropdown-item>
<b-dropdown-item data-cy="logout" v-if="authenticated" @click="logout()" id="logout" active-class="active">
<font-awesome-icon icon="sign-out-alt" />
<span>{{ t$('global.menu.account.logout') }}</span>
</b-dropdown-item>
<b-dropdown-item data-cy="login" v-if="!authenticated" @click="showLogin()" id="login" active-class="active">
<font-awesome-icon icon="sign-in-alt" />
<span>{{ t$('global.menu.account.login') }}</span>
</b-dropdown-item>
<b-dropdown-item data-cy="register" to="/register" id="register" v-if="!authenticated" active-class="active">
<font-awesome-icon icon="user-plus" />
<span>{{ t$('global.menu.account.register') }}</span>
</b-dropdown-item>
</b-nav-item-dropdown>
</b-navbar-nav>
</b-collapse>
</b-navbar>
</template>
<script lang="ts" src="./jhi-navbar.component.ts"></script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
/* ==========================================================================
Navbar
========================================================================== */
.navbar-version {
font-size: 0.65em;
color: #ccc;
}
.navbar .navbar-nav .nav-item {
margin-right: 0.5rem;
}
@media screen and (min-width: 768px) {
.jh-navbar-toggler {
display: none;
}
}
@media screen and (min-width: 768px) and (max-width: 1150px) {
span span {
display: none;
}
}
.navbar-title {
display: inline-block;
color: white;
}
/* ==========================================================================
Logo styles
========================================================================== */
.navbar-brand.logo {
padding: 0 7px;
}
.logo .logo-img {
height: 45px;
display: inline-block;
vertical-align: middle;
width: 45px;
}
.logo-img {
height: 100%;
background: url('/content/images/logo-jhipster.png') no-repeat center center;
background-size: contain;
width: 100%;
filter: drop-shadow(0 0 0.05rem white);
margin: 0 5px;
}
</style>

View File

@@ -0,0 +1,45 @@
import { createTestingPinia } from '@pinia/testing';
import { shallowMount } from '@vue/test-utils';
import { type AccountStore, useStore } from '@/store';
import Ribbon from './ribbon.vue';
type RibbonComponentType = InstanceType<typeof Ribbon>;
const pinia = createTestingPinia({ stubActions: false });
describe('Ribbon', () => {
let ribbon: RibbonComponentType;
let store: AccountStore;
beforeEach(async () => {
const wrapper = shallowMount(Ribbon, {
global: {
plugins: [pinia],
},
});
ribbon = wrapper.vm;
await ribbon.$nextTick();
store = useStore();
store.setRibbonOnProfiles(null);
});
it('should not have ribbonEnabled when no data', () => {
expect(ribbon.ribbonEnabled).toBeFalsy();
});
it('should have ribbonEnabled set to value in store', () => {
const profile = 'dev';
store.setActiveProfiles(['foo', profile, 'bar']);
store.setRibbonOnProfiles(profile);
expect(ribbon.ribbonEnabled).toBeTruthy();
});
it('should not have ribbonEnabled when profile not activated', () => {
const profile = 'dev';
store.setActiveProfiles(['foo', 'bar']);
store.setRibbonOnProfiles(profile);
expect(ribbon.ribbonEnabled).toBeFalsy();
});
});

View File

@@ -0,0 +1,19 @@
import { computed, defineComponent } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from '@/store';
export default defineComponent({
name: 'Ribbon',
setup() {
const store = useStore();
const ribbonEnv = computed(() => store.ribbonOnProfiles);
const ribbonEnabled = computed(() => store.ribbonOnProfiles && store.activeProfiles.indexOf(store.ribbonOnProfiles) > -1);
return {
ribbonEnv,
ribbonEnabled,
t$: useI18n().t,
};
},
});

View File

@@ -0,0 +1,43 @@
<template>
<div class="ribbon" v-if="ribbonEnabled">
<a href="">{{ t$('global.ribbon.' + ribbonEnv) }}</a>
</div>
</template>
<script lang="ts" src="./ribbon.component.ts"></script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
/* ==========================================================================
Development Ribbon
========================================================================== */
.ribbon {
background-color: rgba(170, 0, 0, 0.5);
left: -3.5em;
-moz-transform: rotate(-45deg);
-ms-transform: rotate(-45deg);
-o-transform: rotate(-45deg);
-webkit-transform: rotate(-45deg);
transform: rotate(-45deg);
overflow: hidden;
position: absolute;
top: 40px;
white-space: nowrap;
width: 15em;
z-index: 9999;
pointer-events: none;
opacity: 0.75;
}
.ribbon a {
color: #fff;
display: block;
font-weight: 400;
margin: 1px 0;
padding: 10px 50px;
text-align: center;
text-decoration: none;
text-shadow: 0 0 5px #444;
pointer-events: none;
}
</style>

7
src/main/webapp/app/declarations.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
// These constants are injected via webpack environment variables.
// You can add more variables in webpack.common.js or in profile specific webpack.<dev|prod>.js files.
// If you change the values in the webpack config files, you need to re run webpack to update the application
declare const SERVER_API_URL: string;
declare const APP_VERSION: string;
declare const I18N_HASH: string;

View File

@@ -0,0 +1,12 @@
import { defineComponent } from 'vue';
import { useI18n } from 'vue-i18n';
export default defineComponent({
name: 'EntitiesMenu',
setup() {
const i18n = useI18n();
return {
t$: i18n.t,
};
},
});

View File

@@ -0,0 +1,7 @@
<template>
<div>
<!-- jhipster-needle-add-entity-to-menu - JHipster will add entities to the menu here -->
</div>
</template>
<script lang="ts" src="./entities-menu.component.ts"></script>

View File

@@ -0,0 +1,12 @@
import { defineComponent, provide } from 'vue';
import UserService from '@/entities/user/user.service';
// jhipster-needle-add-entity-service-to-entities-component-import - JHipster will import entities services here
export default defineComponent({
name: 'Entities',
setup() {
provide('userService', () => new UserService());
// jhipster-needle-add-entity-service-to-entities-component - JHipster will import entities services here
},
});

View File

@@ -0,0 +1,5 @@
<template>
<router-view></router-view>
</template>
<script lang="ts" src="./entities.component.ts"></script>

View File

@@ -0,0 +1,9 @@
import axios from 'axios';
const baseApiUrl = 'api/users';
export default class UserService {
retrieve(): Promise<any> {
return axios.get(baseApiUrl);
}
}

View File

@@ -0,0 +1,37 @@
import { type Composer } from 'vue-i18n';
import axios from 'axios';
import dayjs from 'dayjs';
import languages from '@/shared/config/languages';
export default class TranslationService {
private readonly i18n: Composer;
private languages = languages();
constructor(i18n: Composer) {
this.i18n = i18n;
}
async refreshTranslation(newLanguage: string) {
if (this.i18n && !this.i18n.messages[newLanguage]) {
const translations = (await import(`../../i18n/${newLanguage}/${newLanguage}.js`)).default;
this.i18n.setLocaleMessage(newLanguage, translations);
}
}
setLocale(lang: string) {
dayjs.locale(lang);
this.i18n.locale.value = lang;
axios.defaults.headers.common['Accept-Language'] = lang;
document.querySelector('html').setAttribute('lang', lang);
}
isLanguageSupported(lang: string) {
return Boolean(this.languages[lang]);
}
getLocalStoreLanguage(): string | null {
return localStorage.getItem('currentLanguage');
}
}

132
src/main/webapp/app/main.ts Normal file
View File

@@ -0,0 +1,132 @@
// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.common with an alias.
import { computed, createApp, onMounted, provide, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { createPinia, storeToRefs } from 'pinia';
import AccountService from '@/account/account.service';
import { useLoginModal } from '@/account/login-modal';
import LoginService from '@/account/login.service';
import TranslationService from '@/locale/translation.service';
import { setupAxiosInterceptors } from '@/shared/config/axios-interceptor';
import { initFortAwesome, initI18N } from '@/shared/config/config';
import { initBootstrapVue } from '@/shared/config/config-bootstrap-vue';
import JhiItemCount from '@/shared/jhi-item-count.vue';
import JhiSortIndicator from '@/shared/sort/jhi-sort-indicator.vue';
import { useStore, useTranslationStore } from '@/store';
import App from './app.vue';
import router from './router';
import '../content/scss/global.scss';
import '../content/scss/vendor.scss';
const pinia = createPinia();
// jhipster-needle-add-entity-service-to-main-import - JHipster will import entities services here
const i18n = initI18N();
const app = createApp({
components: { App },
setup() {
provide('loginService', new LoginService());
const { hideLogin, showLogin } = useLoginModal();
const store = useStore();
const accountService = new AccountService(store);
const i18n = useI18n();
const translationStore = useTranslationStore();
const translationService = new TranslationService(i18n);
const changeLanguage = async (newLanguage: string) => {
if (i18n.locale.value !== newLanguage) {
await translationService.refreshTranslation(newLanguage);
translationStore.setCurrentLanguage(newLanguage);
}
};
provide('currentLanguage', i18n.locale);
provide('changeLanguage', changeLanguage);
watch(
() => store.account,
async value => {
if (!translationService.getLocalStoreLanguage()) {
await changeLanguage(value.langKey);
}
},
);
watch(
() => translationStore.currentLanguage,
value => {
translationService.setLocale(value);
},
);
onMounted(async () => {
const lang = [translationService.getLocalStoreLanguage(), store.account?.langKey, navigator.language, 'it'].find(
lang => lang && translationService.isLanguageSupported(lang),
);
await changeLanguage(lang);
});
router.beforeResolve(async (to, from, next) => {
// Make sure login modal is closed
hideLogin();
if (!store.authenticated) {
await accountService.update();
}
if (to.meta?.authorities && to.meta.authorities.length > 0) {
const value = await accountService.hasAnyAuthorityAndCheckAuth(to.meta.authorities);
if (!value) {
if (from.path !== '/forbidden') {
next({ path: '/forbidden' });
return;
}
}
}
next();
});
setupAxiosInterceptors(
error => {
const url = error.response?.config?.url;
const status = error.status || error.response?.status;
if (status === 401) {
// Store logged out state.
store.logout();
if (!url.endsWith('api/account') && !url.endsWith('api/authentication')) {
// Ask for a new authentication
showLogin();
return;
}
}
return Promise.reject(error);
},
error => {
return Promise.reject(error);
},
);
const { authenticated } = storeToRefs(store);
provide('authenticated', authenticated);
provide(
'currentUsername',
computed(() => store.account?.login),
);
provide('translationService', translationService);
provide('accountService', accountService);
// jhipster-needle-add-entity-service-to-main - JHipster will import entities services here
},
template: '<App/>',
});
initFortAwesome(app);
initBootstrapVue(app);
app.component('JhiItemCount', JhiItemCount).component('JhiSortIndicator', JhiSortIndicator).use(router).use(pinia).use(i18n).mount('#app');

View File

@@ -0,0 +1,50 @@
import { Authority } from '@/shared/security/authority';
const Register = () => import('@/account/register/register.vue');
const Activate = () => import('@/account/activate/activate.vue');
const ResetPasswordInit = () => import('@/account/reset-password/init/reset-password-init.vue');
const ResetPasswordFinish = () => import('@/account/reset-password/finish/reset-password-finish.vue');
const ChangePassword = () => import('@/account/change-password/change-password.vue');
const Settings = () => import('@/account/settings/settings.vue');
const Sessions = () => import('@/account/sessions/sessions.vue');
export default [
{
path: '/register',
name: 'Register',
component: Register,
},
{
path: '/account/activate',
name: 'Activate',
component: Activate,
},
{
path: '/account/reset/request',
name: 'ResetPasswordInit',
component: ResetPasswordInit,
},
{
path: '/account/reset/finish',
name: 'ResetPasswordFinish',
component: ResetPasswordFinish,
},
{
path: '/account/password',
name: 'ChangePassword',
component: ChangePassword,
meta: { authorities: [Authority.USER] },
},
{
path: '/account/sessions',
name: 'Sessions',
component: Sessions,
meta: { authorities: [Authority.USER] },
},
{
path: '/account/settings',
name: 'Settings',
component: Settings,
meta: { authorities: [Authority.USER] },
},
];

View File

@@ -0,0 +1,67 @@
import { Authority } from '@/shared/security/authority';
const JhiUserManagementComponent = () => import('@/admin/user-management/user-management.vue');
const JhiUserManagementViewComponent = () => import('@/admin/user-management/user-management-view.vue');
const JhiUserManagementEditComponent = () => import('@/admin/user-management/user-management-edit.vue');
const JhiDocsComponent = () => import('@/admin/docs/docs.vue');
const JhiConfigurationComponent = () => import('@/admin/configuration/configuration.vue');
const JhiHealthComponent = () => import('@/admin/health/health.vue');
const JhiLogsComponent = () => import('@/admin/logs/logs.vue');
const JhiMetricsComponent = () => import('@/admin/metrics/metrics.vue');
export default [
{
path: '/admin/user-management',
name: 'JhiUser',
component: JhiUserManagementComponent,
meta: { authorities: [Authority.ADMIN] },
},
{
path: '/admin/user-management/new',
name: 'JhiUserCreate',
component: JhiUserManagementEditComponent,
meta: { authorities: [Authority.ADMIN] },
},
{
path: '/admin/user-management/:userId/edit',
name: 'JhiUserEdit',
component: JhiUserManagementEditComponent,
meta: { authorities: [Authority.ADMIN] },
},
{
path: '/admin/user-management/:userId/view',
name: 'JhiUserView',
component: JhiUserManagementViewComponent,
meta: { authorities: [Authority.ADMIN] },
},
{
path: '/admin/docs',
name: 'JhiDocsComponent',
component: JhiDocsComponent,
meta: { authorities: [Authority.ADMIN] },
},
{
path: '/admin/health',
name: 'JhiHealthComponent',
component: JhiHealthComponent,
meta: { authorities: [Authority.ADMIN] },
},
{
path: '/admin/logs',
name: 'JhiLogsComponent',
component: JhiLogsComponent,
meta: { authorities: [Authority.ADMIN] },
},
{
path: '/admin/metrics',
name: 'JhiMetricsComponent',
component: JhiMetricsComponent,
meta: { authorities: [Authority.ADMIN] },
},
{
path: '/admin/configuration',
name: 'JhiConfigurationComponent',
component: JhiConfigurationComponent,
meta: { authorities: [Authority.ADMIN] },
},
];

View File

@@ -0,0 +1,11 @@
const Entities = () => import('@/entities/entities.vue');
// jhipster-needle-add-entity-to-router-import - JHipster will import entities to the router here
export default {
path: '/',
component: Entities,
children: [
// jhipster-needle-add-entity-to-router - JHipster will add entities to the router here
],
};

View File

@@ -0,0 +1,48 @@
import { createRouter as createVueRouter, createWebHistory } from 'vue-router';
const Home = () => import('@/core/home/home.vue');
const Error = () => import('@/core/error/error.vue');
import account from '@/router/account';
import admin from '@/router/admin';
import entities from '@/router/entities';
import pages from '@/router/pages';
export const createRouter = () =>
createVueRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'Home',
component: Home,
},
{
path: '/forbidden',
name: 'Forbidden',
component: Error,
meta: { error403: true },
},
{
path: '/not-found',
name: 'NotFound',
component: Error,
meta: { error404: true },
},
...account,
...admin,
entities,
...pages,
],
});
const router = createRouter();
router.beforeResolve(async (to, from, next) => {
if (!to.matched.length) {
next({ path: '/not-found' });
return;
}
next();
});
export default router;

View File

@@ -0,0 +1,5 @@
// jhipster-needle-add-entity-to-router-import - JHipster will import entities to the router here
export default [
// jhipster-needle-add-entity-to-router - JHipster will add entities to the router here
];

View File

@@ -0,0 +1,191 @@
import { beforeEach, describe, expect, vitest } from 'vitest';
import AlertService from './alert.service';
describe('Alert Service test suite', () => {
let translationStub: vitest.Mock;
let toastStub: vitest.Mock;
let alertService: AlertService;
beforeEach(() => {
translationStub = vitest.fn();
toastStub = vitest.fn();
alertService = new AlertService({
i18n: { t: translationStub } as any,
toast: {
show: toastStub,
} as any,
});
});
it('should show error toast with translation/message', () => {
const message = 'translatedMessage';
// WHEN
alertService.showError(message);
// THEN
expect(toastStub).toHaveBeenCalledExactlyOnceWith({
props: {
body: message,
pos: 'top-center',
title: 'Error',
variant: 'danger',
solid: true,
},
});
});
it('should show not reachable toast when http status = 0', () => {
const translationKey = 'error.server.not.reachable';
const message = 'Server not reachable';
const httpErrorResponse = {
status: 0,
};
// GIVEN
translationStub.mockReturnValueOnce(message);
// WHEN
alertService.showHttpError(httpErrorResponse);
// THEN
expect(translationStub).toHaveBeenCalledExactlyOnceWith(translationKey);
expect(toastStub).toHaveBeenCalledExactlyOnceWith({
props: {
body: expect.any(String),
pos: 'top-center',
solid: true,
title: 'Error',
variant: 'danger',
},
});
});
it('should show parameterized error toast when http status = 400 and entity headers', () => {
const translationKey = 'error.update';
const message = 'Updation Error';
const httpErrorResponse = {
status: 400,
headers: {
'x-jhipsterapp-error': translationKey,
'x-jhipsterapp-params': 'dummyEntity',
},
};
// GIVEN
translationStub.mockImplementation(key => {
if (key === translationKey) {
return message;
}
if (key === 'global.menu.entities.dummyEntity') {
return 'DummyEntity';
}
throw new Error();
});
// WHEN
alertService.showHttpError(httpErrorResponse);
// THEN
expect(translationStub).toHaveBeenCalledTimes(2);
expect(translationStub).toHaveBeenCalledWith(translationKey, { entityName: 'DummyEntity' });
expect(translationStub).toHaveBeenCalledWith('global.menu.entities.dummyEntity');
expect(toastStub).toHaveBeenCalledWith({
props: {
body: expect.any(String),
pos: 'top-center',
solid: true,
title: 'Error',
variant: 'danger',
},
});
});
it('should show error toast with data.message when http status = 400 and entity headers', () => {
const message = 'Validation error';
const httpErrorResponse = {
status: 400,
headers: {
'x-jhipsterapp-error400': 'error',
'x-jhipsterapp-params400': 'dummyEntity',
},
data: {
message,
fieldErrors: {
field1: 'error1',
},
},
};
// GIVEN
translationStub.mockReturnValueOnce(message);
// WHEN
alertService.showHttpError(httpErrorResponse);
// THEN
expect(translationStub).toHaveBeenCalledExactlyOnceWith(message);
expect(toastStub).toHaveBeenCalledExactlyOnceWith({
props: {
body: expect.any(String),
pos: 'top-center',
solid: true,
title: 'Error',
variant: 'danger',
},
});
});
it('should show error toast when http status = 404', () => {
const translationKey = 'error.http.404';
const message = 'The page does not exist.';
const httpErrorResponse = {
status: 404,
};
// GIVEN
translationStub.mockReturnValueOnce(message);
// WHEN
alertService.showHttpError(httpErrorResponse);
// THEN
expect(translationStub).toHaveBeenCalledExactlyOnceWith(translationKey);
expect(toastStub).toHaveBeenCalledExactlyOnceWith({
props: {
body: expect.any(String),
pos: 'top-center',
solid: true,
title: 'Error',
variant: 'danger',
},
});
});
it('should show error toast when http status != 400,404', () => {
const message = 'Error 500';
const httpErrorResponse = {
status: 500,
data: {
message,
},
};
// GIVEN
translationStub.mockReturnValueOnce(message);
// WHEN
alertService.showHttpError(httpErrorResponse);
// THEN
expect(translationStub).toHaveBeenCalledExactlyOnceWith(message);
expect(toastStub).toHaveBeenCalledExactlyOnceWith({
props: {
body: expect.any(String),
pos: 'top-center',
solid: true,
title: 'Error',
variant: 'danger',
},
});
});
});

View File

@@ -0,0 +1,87 @@
import { type Composer, useI18n } from 'vue-i18n';
import { type BToastProps, useToast } from 'bootstrap-vue-next';
export const useAlertService = () => {
const toast = useToast();
if (!toast) {
throw new Error('BootstrapVue toast component was not found');
}
const i18n = useI18n();
return new AlertService({
toast,
i18n,
});
};
export default class AlertService {
private toast: ReturnType<typeof useToast>;
private i18n: Composer;
constructor({ toast, i18n }: { toast: ReturnType<typeof useToast>; i18n: Composer }) {
this.toast = toast;
this.i18n = i18n;
}
showInfo(toastMessage: string, props: BToastProps = {}) {
this.toast.show!({
props: {
pos: 'top-center',
title: 'Info',
variant: 'info',
solid: true,
body: toastMessage,
...props,
},
});
}
showSuccess(toastMessage: string) {
this.showInfo(toastMessage, {
title: 'Success',
variant: 'success',
});
}
showError(toastMessage: string) {
this.showInfo(toastMessage, {
title: 'Error',
variant: 'danger',
});
}
showHttpError(httpErrorResponse: any) {
let errorMessage: string | null = null;
switch (httpErrorResponse.status) {
case 0:
errorMessage = this.i18n.t('error.server.not.reachable').toString();
break;
case 400: {
const arr = Object.keys(httpErrorResponse.headers);
let entityKey: string | null = null;
for (const entry of arr) {
if (entry.toLowerCase().endsWith('app-error')) {
errorMessage = httpErrorResponse.headers[entry];
} else if (entry.toLowerCase().endsWith('app-params')) {
entityKey = httpErrorResponse.headers[entry];
}
}
if (errorMessage && entityKey) {
errorMessage = this.i18n.t(errorMessage, { entityName: this.i18n.t(`global.menu.entities.${entityKey}`) }).toString();
} else if (!errorMessage) {
errorMessage = this.i18n.t(httpErrorResponse.data.message).toString();
}
break;
}
case 404:
errorMessage = this.i18n.t('error.http.404').toString();
break;
default:
errorMessage = this.i18n.t(httpErrorResponse.data.message).toString();
}
this.showError(errorMessage);
}
}

Some files were not shown because too many files have changed in this diff Show More