Cleaned up UserService.java, refactored user page to use profile information instead of other endpoints.

This commit is contained in:
Andrew Lalis 2023-03-25 11:29:15 +01:00
parent 346c5d9813
commit bb5cf53908
19 changed files with 616 additions and 4852 deletions

View File

@ -50,6 +50,7 @@ public class SecurityConfig {
"/submissions/**",
"/auth/reset-password",
"/auth/users/*",
"/auth/users/*/profile",
"/auth/users/*/access",
"/auth/users/*/followers",
"/auth/users/*/following",

View File

@ -2,6 +2,7 @@ package nl.andrewlalis.gymboard_api.domains.auth.controller;
import nl.andrewlalis.gymboard_api.domains.auth.dto.*;
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
import nl.andrewlalis.gymboard_api.domains.auth.model.UserPreferences;
import nl.andrewlalis.gymboard_api.domains.auth.service.UserAccessService;
import nl.andrewlalis.gymboard_api.domains.auth.service.UserService;
import org.springframework.data.domain.Page;
@ -36,6 +37,19 @@ public class UserController {
return userService.getUser(userId);
}
/**
* Gets all the information that the app will typically need to display a
* user's profile page. Some information may be omitted if the user has
* set their {@link UserPreferences#isAccountPrivate()}
* to true, and the requesting user isn't following them.
* @param userId The id of the user to fetch profile information for.
* @return The user's profile information.
*/
@GetMapping(path = "/auth/users/{userId}/profile")
public UserProfileResponse getUserProfile(@PathVariable String userId) {
return userService.getProfile(userId);
}
@GetMapping(path = "/auth/users/{userId}/access")
public UserAccessResponse getUserAccess(@PathVariable String userId) {
return new UserAccessResponse(userAccessService.currentUserHasAccess(userId));

View File

@ -1,5 +1,6 @@
package nl.andrewlalis.gymboard_api.domains.auth.dao;
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
import nl.andrewlalis.gymboard_api.domains.auth.model.UserFollowRequest;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
@ -11,4 +12,6 @@ import java.time.LocalDateTime;
public interface UserFollowRequestRepository extends JpaRepository<UserFollowRequest, Long> {
@Modifying
void deleteAllByCreatedAtBefore(LocalDateTime cutoff);
boolean existsByRequestingUserAndUserToFollowAndApprovedIsNull(User requestingUser, User userToFollow);
}

View File

@ -20,4 +20,6 @@ public interface UserFollowingRepository extends JpaRepository<UserFollowing, Lo
long countByFollowedUser(User followedUser);
long countByFollowingUser(User followingUser);
long countByFollowedUserId(String id);
long countByFollowingUserId(String id);
}

View File

@ -0,0 +1,15 @@
package nl.andrewlalis.gymboard_api.domains.auth.dto;
/**
* Response that contains all the information needed to show a user's profile
* page.
*/
public record UserProfileResponse(
String id,
String name,
String followerCount,
String followingCount,
boolean followingThisUser,
boolean accountPrivate,
boolean canAccessThisUser
) {}

View File

@ -0,0 +1,48 @@
package nl.andrewlalis.gymboard_api.domains.auth.service;
import nl.andrewlalis.gymboard_api.domains.auth.dao.EmailResetCodeRepository;
import nl.andrewlalis.gymboard_api.domains.auth.dao.PasswordResetCodeRepository;
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserActivationCodeRepository;
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserFollowRequestRepository;
import nl.andrewlalis.gymboard_api.domains.auth.model.EmailResetCode;
import nl.andrewlalis.gymboard_api.domains.auth.model.PasswordResetCode;
import nl.andrewlalis.gymboard_api.domains.auth.model.UserActivationCode;
import nl.andrewlalis.gymboard_api.domains.auth.model.UserFollowRequest;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
@Service
public class CleanupService {
private final PasswordResetCodeRepository passwordResetCodeRepository;
private final UserActivationCodeRepository activationCodeRepository;
private final UserFollowRequestRepository followRequestRepository;
private final EmailResetCodeRepository emailResetCodeRepository;
public CleanupService(PasswordResetCodeRepository passwordResetCodeRepository, UserActivationCodeRepository activationCodeRepository, UserFollowRequestRepository followRequestRepository, EmailResetCodeRepository emailResetCodeRepository) {
this.passwordResetCodeRepository = passwordResetCodeRepository;
this.activationCodeRepository = activationCodeRepository;
this.followRequestRepository = followRequestRepository;
this.emailResetCodeRepository = emailResetCodeRepository;
}
/**
* Scheduled task that periodically removes all old authentication entities
* so that they don't clutter up the system.
*/
@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.HOURS)
@Transactional
public void removeOldAuthEntities() {
LocalDateTime passwordResetCodeCutoff = LocalDateTime.now().minus(PasswordResetCode.VALID_FOR);
passwordResetCodeRepository.deleteAllByCreatedAtBefore(passwordResetCodeCutoff);
LocalDateTime activationCodeCutoff = LocalDateTime.now().minus(UserActivationCode.VALID_FOR);
activationCodeRepository.deleteAllByCreatedAtBefore(activationCodeCutoff);
LocalDateTime followRequestCutoff = LocalDateTime.now().minus(UserFollowRequest.VALID_FOR);
followRequestRepository.deleteAllByCreatedAtBefore(followRequestCutoff);
LocalDateTime emailResetCodeCutoff = LocalDateTime.now().minus(EmailResetCode.VALID_FOR);
emailResetCodeRepository.deleteAllByCreatedAtBefore(emailResetCodeCutoff);
}
}

View File

@ -29,7 +29,7 @@ import java.time.LocalDateTime;
import java.util.Random;
import java.util.concurrent.TimeUnit;
import static nl.andrewlalis.gymboard_api.util.DataUtils.findByIdOrThrow;
import static nl.andrewlalis.gymboard_api.util.DataUtils.*;
@Service
public class UserService {
@ -283,23 +283,6 @@ public class UserService {
emailResetCodeRepository.delete(emailResetCode);
}
/**
* Scheduled task that periodically removes all old authentication entities
* so that they don't clutter up the system.
*/
@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.HOURS)
@Transactional
public void removeOldAuthEntities() {
LocalDateTime passwordResetCodeCutoff = LocalDateTime.now().minus(PasswordResetCode.VALID_FOR);
passwordResetCodeRepository.deleteAllByCreatedAtBefore(passwordResetCodeCutoff);
LocalDateTime activationCodeCutoff = LocalDateTime.now().minus(UserActivationCode.VALID_FOR);
activationCodeRepository.deleteAllByCreatedAtBefore(activationCodeCutoff);
LocalDateTime followRequestCutoff = LocalDateTime.now().minus(UserFollowRequest.VALID_FOR);
followRequestRepository.deleteAllByCreatedAtBefore(followRequestCutoff);
LocalDateTime emailResetCodeCutoff = LocalDateTime.now().minus(EmailResetCode.VALID_FOR);
emailResetCodeRepository.deleteAllByCreatedAtBefore(emailResetCodeCutoff);
}
@Transactional
public UserPersonalDetailsResponse updatePersonalDetails(String id, UserPersonalDetailsPayload payload) {
User user = userRepository.findById(id)
@ -366,12 +349,14 @@ public class UserService {
User followed = findByIdOrThrow(followedId, userRepository);
if (!userFollowingRepository.existsByFollowedUserAndFollowingUser(followed, follower)) {
if (followed.getPreferences().isAccountPrivate()) {
if (!followed.getPreferences().isAccountPrivate()) {
userFollowingRepository.save(new UserFollowing(followed, follower));
return UserFollowResponse.requested();
} else {
followRequestRepository.save(new UserFollowRequest(follower, followed));
return UserFollowResponse.followed();
} else {
if (!followRequestRepository.existsByRequestingUserAndUserToFollowAndApprovedIsNull(follower, followed)) {
followRequestRepository.save(new UserFollowRequest(follower, followed));
}
return UserFollowResponse.requested();
}
}
return UserFollowResponse.alreadyFollowed();
@ -382,7 +367,6 @@ public class UserService {
if (followerId.equals(followedId)) return;
User follower = findByIdOrThrow(followerId, userRepository);
User followed = findByIdOrThrow(followedId, userRepository);
userFollowingRepository.deleteByFollowedUserAndFollowingUser(followed, follower);
}
@ -422,12 +406,14 @@ public class UserService {
.map(UserResponse::new);
}
public long getFollowerCount(String userId) {
return userFollowingRepository.countByFollowedUser(findByIdOrThrow(userId, userRepository));
public String getFollowerCount(String userId) {
long rawCount = userFollowingRepository.countByFollowedUserId(userId);
return formatLargeInt(fuzzInt(rawCount));
}
public long getFollowingCount(String userId) {
return userFollowingRepository.countByFollowingUser(findByIdOrThrow(userId, userRepository));
public String getFollowingCount(String userId) {
long rawCount = userFollowingRepository.countByFollowingUserId(userId);
return formatLargeInt(fuzzInt(rawCount));
}
@Transactional(readOnly = true)
@ -457,4 +443,23 @@ public class UserService {
payload.description()
));
}
@Transactional
public UserProfileResponse getProfile(String userId) {
User user = findByIdOrThrow(userId, userRepository);
boolean canAccessThisUser = userAccessService.currentUserHasAccess(user);
boolean followingThisUser = false;
if (SecurityContextHolder.getContext().getAuthentication() instanceof TokenAuthentication t) {
followingThisUser = userFollowingRepository.existsByFollowedUserAndFollowingUser(user, t.user());
}
return new UserProfileResponse(
user.getId(),
user.getName(),
getFollowerCount(user.getId()),
getFollowingCount(user.getId()),
followingThisUser,
user.getPreferences().isAccountPrivate(),
canAccessThisUser
);
}
}

View File

@ -17,4 +17,53 @@ public class DataUtils {
public static <T, ID> T findByIdOrThrow(ID id, CrudRepository<T, ID> repo) throws ResponseStatusException {
return repo.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}
/**
* Formats a large integer value as a human-readable string with a size
* suffix. For example, instead of 1,245,300, we would return "1.2M".
* @param value The value to format.
* @return The string representation of the value.
*/
public static String formatLargeInt(long value) {
if (value < 1000) return Long.toString(value);
long scale = 1000;
final long MAX_SCALE = 1_000_000_000;
final char[] SCALE_SUFFIXES = {'K', 'M', 'B'};
int scaleSuffixIdx = 0;
while (scale <= MAX_SCALE) {
if (value < scale * 1000 || scale == MAX_SCALE) {
long baseValue = value / scale;
long remainderDigit = (value % scale) / (scale / 10);
StringBuilder sb = new StringBuilder(6);
sb.append(baseValue);
if (remainderDigit > 0) sb.append('.').append(remainderDigit);
sb.append(SCALE_SUFFIXES[scaleSuffixIdx]);
return sb.toString();
}
scale *= 1000;
scaleSuffixIdx++;
}
throw new IllegalStateException();
}
/**
* "Fuzzes" an integer value such that it is randomly incremented or
* decremented by some amount proportional to its original value, so that
* its original true value is obscured slightly.
* @param value The value to fuzz.
* @return The fuzzed value.
*/
public static long fuzzInt(long value) {
double fuzz;
if (value < 1000) {
fuzz = 0.01;
} else if (value < 1_000_000) {
fuzz = 0.0001;
} else {
fuzz = 0.000001;
}
double modification = fuzz * Math.random() * value;
return value + Math.round(modification);
}
}

View File

@ -0,0 +1,26 @@
package nl.andrewlalis.gymboard_api.util;
import org.junit.jupiter.api.Test;
import static nl.andrewlalis.gymboard_api.util.DataUtils.formatLargeInt;
import static org.junit.jupiter.api.Assertions.*;
public class DataUtilsTest {
@Test
public void testFormatLargeInt() {
assertEquals("0", formatLargeInt(0));
assertEquals("1", formatLargeInt(1));
assertEquals("42", formatLargeInt(42));
assertEquals("999", formatLargeInt(999));
assertEquals("1K", formatLargeInt(1000));
assertEquals("1K", formatLargeInt(1099));
assertEquals("1.1K", formatLargeInt(1100));
assertEquals("1.1K", formatLargeInt(1199));
assertEquals("25.2K", formatLargeInt(25_231));
assertEquals("999K", formatLargeInt(999_000));
assertEquals("1M", formatLargeInt(1_000_000));
assertEquals("1.5M", formatLargeInt(1_500_000));
assertEquals("3B", formatLargeInt(3_000_000_000L));
assertEquals("1024B", formatLargeInt(1_024_000_000_000L));
}
}

View File

@ -5,4 +5,4 @@
echo "Starting gymboard-api development server."
./gen_keys.d
./mvnw spring-boot:run -Dspring-boot.run.profiles=development
./mvnw clean spring-boot:run -Dspring-boot.run.profiles=development

File diff suppressed because it is too large Load Diff

View File

@ -49,6 +49,22 @@ export interface UserCreationPayload {
password: string;
}
export interface UserProfile {
id: string;
name: string;
followerCount: string;
followingCount: string;
followingThisUser: boolean;
accountPrivate: boolean;
canAccessThisUser: boolean;
}
export enum UserFollowResponse {
FOLLOWED = 'FOLLOWED',
REQUESTED = 'REQUESTED',
ALREADY_FOLLOWED = 'ALREADY_FOLLOWED'
}
class AuthModule {
private static readonly TOKEN_REFRESH_INTERVAL_MS = 30000;
@ -115,6 +131,11 @@ class AuthModule {
return response.data;
}
public async getUserProfile(userId: string, authStore: AuthStoreType): Promise<UserProfile> {
const response = await api.get(`/auth/users/${userId}/profile`, authStore.axiosConfig);
return response.data;
}
public async isUserAccessible(
userId: string,
authStore: AuthStoreType
@ -245,7 +266,7 @@ class AuthModule {
public async followUser(
userId: string,
authStore: AuthStoreType
): Promise<string> {
): Promise<UserFollowResponse> {
const response = await api.post(
`/auth/users/${userId}/followers`,
undefined,

View File

@ -21,8 +21,8 @@ import { useRoute, useRouter } from 'vue-router';
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
import { getGymFromRoute, getGymRoute } from 'src/router/gym-routing';
import { Gym } from 'src/api/main/gyms';
import PageMenu from "components/PageMenu.vue";
import {useI18n} from "vue-i18n";
import PageMenu from 'components/PageMenu.vue';
import {useI18n} from 'vue-i18n';
const route = useRoute();
const router = useRouter();
@ -39,14 +39,4 @@ onMounted(async () => {
await router.push('/');
}
});
const homePageSelected = computed(
() => gym.value && getGymRoute(gym.value) === route.fullPath
);
const submitPageSelected = computed(
() => gym.value && route.fullPath === getGymRoute(gym.value) + '/submit'
);
const leaderboardPageSelected = computed(
() => gym.value && route.fullPath === getGymRoute(gym.value) + '/leaderboard'
);
</script>

View File

@ -12,20 +12,20 @@
</template>
<script setup lang="ts">
import {User} from "src/api/main/auth";
import {useAuthStore} from "stores/auth-store";
import {onMounted, ref, Ref} from "vue";
import {User} from 'src/api/main/auth';
import {useAuthStore} from 'stores/auth-store';
import {onMounted, ref, Ref} from 'vue';
import api from 'src/api/main';
interface Props {
user: User;
userId: string
}
const props = defineProps<Props>();
const authStore = useAuthStore();
const followers: Ref<User[]> = ref([]);
onMounted(async () => {
followers.value = await api.auth.getFollowers(props.user.id, authStore, 0, 10);
followers.value = await api.auth.getFollowers(props.userId, authStore, 0, 10);
});
</script>

View File

@ -9,13 +9,13 @@
</template>
<script setup lang="ts">
import { User } from 'src/api/main/auth';
import { useAuthStore } from 'stores/auth-store';
import { onMounted, ref, Ref } from 'vue';
import {User} from 'src/api/main/auth';
import {useAuthStore} from 'stores/auth-store';
import {onMounted, ref, Ref} from 'vue';
import api from 'src/api/main';
interface Props {
user: User;
userId: string;
}
const props = defineProps<Props>();
const authStore = useAuthStore();
@ -23,7 +23,7 @@ const following: Ref<User[]> = ref([]);
onMounted(async () => {
following.value = await api.auth.getFollowing(
props.user.id,
props.userId,
authStore,
0,
10

View File

@ -1,15 +1,23 @@
<template>
<q-page>
<StandardCenteredPage v-if="user">
<h3>{{ user?.name }}</h3>
<StandardCenteredPage v-if="profile">
<h3>{{ profile.name }}</h3>
<div v-if="devStore.showDebugInfo">
<div>Private: {{ profile.accountPrivate }}</div>
</div>
<div v-if="relationship">
<q-btn v-if="!relationship.following" label="Follow" @click="followUser"/>
<q-btn v-if="relationship.following" label="Unfollow" @click="unfollowUser"/>
<div>
<div>Followers: {{ profile.followerCount }}</div>
<div>Following: {{ profile.followingCount }}</div>
</div>
<div v-if="authStore.loggedIn && !isOwnUser">
<q-btn v-if="!profile.followingThisUser" label="Follow" @click="followUser"/>
<q-btn v-if="profile.followingThisUser" label="Unfollow" @click="unfollowUser"/>
</div>
<PageMenu
:base-route="`/users/${user.id}`"
:base-route="`/users/${profile.id}`"
:items="[
{label: 'Lifts', to: ''},
{label: 'Followers', to: 'followers'},
@ -18,18 +26,21 @@
/>
<!-- Sub-pages are rendered here. -->
<div v-if="userAccessible">
<UserSubmissionsPage :user="user" v-if="route.path === getUserRoute(user)"/>
<UserFollowersPage :user="user" v-if="route.path === getUserRoute(user) + '/followers'"/>
<UserFollowingPage :user="user" v-if="route.path === getUserRoute(user) + '/following'"/>
<div v-if="profile.canAccessThisUser">
<UserSubmissionsPage :userId="profile.id" v-if="route.path === `/users/${profile.id}`"/>
<UserFollowersPage :userId="profile.id" v-if="route.path === `/users/${profile.id}/followers`"/>
<UserFollowingPage :userId="profile.id" v-if="route.path === `/users/${profile.id}/following`"/>
</div>
<div v-if="!userAccessible">
<!-- If the user can't be accessed, show a placeholder message instead. -->
<div v-if="!profile.canAccessThisUser">
This account is private.
</div>
</StandardCenteredPage>
<StandardCenteredPage v-if="userNotFound">
<!-- If no user profile was loaded, we show a generic "User not found" message and no content. -->
<StandardCenteredPage v-if="!profile">
<h3>{{ $t('userPage.notFound.title') }}</h3>
<p>{{ $t('userPage.notFound.description') }}</p>
</StandardCenteredPage>
@ -39,36 +50,34 @@
<script setup lang="ts">
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
import {onMounted, ref, Ref, watch} from 'vue';
import {User, UserRelationship} from 'src/api/main/auth';
import {UserFollowResponse, UserProfile} from 'src/api/main/auth';
import api from 'src/api/main';
import {useRoute} from 'vue-router';
import {useAuthStore} from 'stores/auth-store';
import {useI18n} from 'vue-i18n';
import {useQuasar} from 'quasar';
import {showApiErrorToast} from 'src/utils';
import {showApiErrorToast, showInfoToast} from 'src/utils';
import PageMenu from 'components/PageMenu.vue';
import UserSubmissionsPage from 'pages/user/UserSubmissionsPage.vue';
import {getUserRoute} from 'src/router/user-routing';
import UserFollowersPage from 'pages/user/UserFollowersPage.vue';
import UserFollowingPage from 'pages/user/UserFollowingPage.vue';
import {useDevStore} from 'stores/dev-store';
const route = useRoute();
const authStore = useAuthStore();
const devStore = useDevStore();
const i18n = useI18n();
const quasar = useQuasar();
const user: Ref<User | undefined> = ref();
const relationship: Ref<UserRelationship | undefined> = ref();
const profile: Ref<UserProfile | undefined> = ref();
const isOwnUser = ref(false);
const userNotFound = ref(false);
const userAccessible = ref(false);
// If the user id changes, we have to manually reload the new user, since we
// will end up on the same route component, which means the router won't
// re-render.
watch(route, async (updatedRoute) => {
const userId = updatedRoute.params.userId[0];
if (!user.value || user.value.id !== userId) {
if (!profile.value || (profile.value.id !== userId)) {
await loadUser(userId);
}
});
@ -80,41 +89,24 @@ onMounted(async () => {
async function loadUser(id: string) {
try {
user.value = await api.auth.getUser(id, authStore);
isOwnUser.value = authStore.loggedIn && user.value.id === authStore.user?.id;
userAccessible.value = await api.auth.isUserAccessible(id, authStore);
await loadRelationship();
profile.value = await api.auth.getUserProfile(id, authStore);
isOwnUser.value = authStore.loggedIn && profile.value.id === authStore.user?.id;
} catch (error: any) {
user.value = undefined;
relationship.value = undefined;
isOwnUser.value = false;
userAccessible.value = false;
if (error.response && error.response.status === 404) {
userNotFound.value = true;
} else {
if (!(error.response && error.response.status === 404)) {
showApiErrorToast(i18n, quasar);
}
}
}
async function loadRelationship() {
if (authStore.user && user.value && userAccessible.value && authStore.user.id !== user.value.id) {
try {
relationship.value = await api.auth.getRelationshipTo(authStore.user.id, user.value.id, authStore);
} catch (error) {
relationship.value = undefined;
showApiErrorToast(i18n, quasar);
}
} else {
relationship.value = undefined;
}
}
async function followUser() {
if (user.value) {
if (profile.value && !profile.value.followingThisUser) {
try {
await api.auth.followUser(user.value?.id, authStore);
await loadRelationship();
const result = await api.auth.followUser(profile.value.id, authStore);
if (result === UserFollowResponse.FOLLOWED) {
await loadUser(profile.value.id);
} else if (result === UserFollowResponse.REQUESTED) {
showInfoToast(quasar, 'Requested to follow this user!');
}
} catch (error) {
showApiErrorToast(i18n, quasar);
}
@ -122,10 +114,10 @@ async function followUser() {
}
async function unfollowUser() {
if (user.value) {
if (profile.value && profile.value.followingThisUser) {
try {
await api.auth.unfollowUser(user.value?.id, authStore);
await loadRelationship();
await api.auth.unfollowUser(profile.value.id, authStore);
await loadUser(profile.value.id);
} catch (error) {
showApiErrorToast(i18n, quasar);
}

View File

@ -19,13 +19,12 @@ import {useQuasar} from 'quasar';
import {useAuthStore} from 'stores/auth-store';
import {onMounted, ref, Ref} from 'vue';
import {ExerciseSubmission} from 'src/api/main/submission';
import {User} from 'src/api/main/auth';
import api from 'src/api/main';
import ExerciseSubmissionListItem from 'components/ExerciseSubmissionListItem.vue';
import {showApiErrorToast} from 'src/utils';
interface Props {
user: User;
userId: string;
}
const props = defineProps<Props>();
@ -36,7 +35,7 @@ const authStore = useAuthStore();
const recentSubmissions: Ref<ExerciseSubmission[]> = ref([]);
onMounted(async () => {
try {
recentSubmissions.value = await api.users.getRecentSubmissions(props.user.id, authStore);
recentSubmissions.value = await api.users.getRecentSubmissions(props.userId, authStore);
} catch (error: any) {
showApiErrorToast(i18n, quasar);
}

View File

@ -0,0 +1,17 @@
/**
* This store keeps track of global state that's only relevant for development
* purposes, like debug flags and settings.
*/
import {defineStore} from 'pinia';
interface DevState {
showDebugInfo: boolean;
}
export const useDevStore = defineStore('devStore', {
state: (): DevState => {
return { showDebugInfo: !!process.env.DEV };
}
});
export type DevStoreType = ReturnType<typeof useDevStore>;

View File

@ -1,4 +1,4 @@
import {QVueGlobals} from "quasar";
import {QVueGlobals} from 'quasar';
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
@ -9,3 +9,11 @@ export function showApiErrorToast(i18n: any, quasar: QVueGlobals) {
position: 'top'
});
}
export function showInfoToast(quasar: QVueGlobals, translatedMessage: string) {
quasar.notify({
message: translatedMessage,
type: 'info',
position: 'top'
});
}