Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

LBGG-564: Account confirmation #580

Merged
merged 11 commits into from
Nov 29, 2023
1 change: 1 addition & 0 deletions assets/sprite/svg/circle-check.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions assets/sprite/svg/circle-exclamation.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions assets/sprite/svg/circle-info.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions assets/sprite/svg/spinner.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions assets/sprite/svg/triangle-exclamation.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@ export {}

declare module 'vue' {
export interface GlobalComponents {
ISvgCircleCheck: typeof import('~icons/svg/circle-check')['default']
ISvgCircleExclamation: typeof import('~icons/svg/circle-exclamation')['default']
ISvgCircleInfo: typeof import('~icons/svg/circle-info')['default']
ISvgClock: typeof import('~icons/svg/clock')['default']
ISvgClose: typeof import('~icons/svg/close')['default']
ISvgEyeHidden: typeof import('~icons/svg/eye-hidden')['default']
ISvgEyeVisible: typeof import('~icons/svg/eye-visible')['default']
ISvgMenu: typeof import('~icons/svg/menu')['default']
ISvgSearch: typeof import('~icons/svg/search')['default']
ISvgSpinner: typeof import('~icons/svg/spinner')['default']
ISvgTriangleExclamation: typeof import('~icons/svg/triangle-exclamation')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
Expand Down
31 changes: 31 additions & 0 deletions components/blocks/Loader/Loader.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<script setup lang="ts"></script>

<template>
<div class="loader__container">
<i-svg-spinner class="loader__spinner" />
</div>
</template>

<style lang="postcss" scoped>
.loader__container {
@apply flex justify-center items-center h-screen w-full;
}

.loader__spinner {
@apply h-20 w-20 fill-current;
animation: spin 1.25s steps(9, end) infinite;

@media (prefers-reduced-motion) {
animation-duration: 9s;
}
zysim marked this conversation as resolved.
Show resolved Hide resolved

@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
}
</style>
60 changes: 60 additions & 0 deletions components/blocks/cards/BasicAlert/BasicAlert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { mount, enableAutoUnmount } from '@vue/test-utils'
import { useModalAlert } from 'composables/useModalAlert'
import { getByClass, getByTestId, getHTMLElement } from 'root/testUtils'
import BasicAlert from './BasicAlert.vue'

function getBasicAlertWrapper() {
return mount(BasicAlert)
}

beforeEach(() => {
const modalAlertState = useModalAlert()
modalAlertState.value = {
body: 'This is a test',
show: true,
title: 'A test alert?',
type: 'info',
}
})

enableAutoUnmount(afterEach)

afterEach(() => {
fetchMock.resetMocks()
vi.restoreAllMocks()
})

describe('<BasicAlert />', () => {
it('should render without crashing', () => {
const wrapper = getBasicAlertWrapper()

expect(wrapper.isVisible()).toBe(true)
})

it('renders with the correct information', () => {
const wrapper = getBasicAlertWrapper()

expect(
getHTMLElement(getByClass(wrapper, 'basic-modal-alert__header'))
.childElementCount,
).toEqual(3)
expect(getByClass(wrapper, 'basic-modal-alert__title').text()).toEqual(
'A test alert?',
)
expect(getByClass(wrapper, 'basic-modal-alert__body').text()).toEqual(
'This is a test',
)
})

describe('when the close button is clicked', () => {
it('should emit the close event', async () => {
const wrapper = getBasicAlertWrapper()

await getByTestId(wrapper, 'basic-modal-alert-close-button').trigger(
'click',
)

expect(wrapper.isVisible()).toBe(false)
})
})
})
99 changes: 99 additions & 0 deletions components/blocks/cards/BasicAlert/BasicAlert.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<script setup lang="ts">
import BaseModal from 'elements/modals/BaseModal/BaseModal.vue'
import CloseButton from 'elements/buttons/CloseButton/CloseButton.vue'
import Card from 'elements/cards/Card/Card.vue'
import CardHeader from 'elements/cards/CardHeader/CardHeader.vue'
import CardBody from 'elements/cards/CardBody/CardBody.vue'
import { useModalAlert } from 'composables/useModalAlert'

const modalAlertState = useModalAlert()?.value

function close() {
modalAlertState.body = ''
modalAlertState.show = false
modalAlertState.title = ''
modalAlertState.type = ''
}
</script>

<template>
<transition
v-if="modalAlertState.show"
enter-active-class="transition-opacity duration-200"
leave-active-class="transition-opacity duration-200"
enter-to-class="opacity-100"
leave-to-class="opacity-0"
>
<BaseModal v-show="modalAlertState.show" @close="close">
<Card
id="basicModalAlert"
:class="['basic-modal-alert', modalAlertState.type]"
data-testid="basic-modal-alert"
>
<CardHeader class="basic-modal-alert__header">
<i-svg-circle-info v-if="modalAlertState.type === 'info'" />
<i-svg-circle-check v-if="modalAlertState.type === 'success'" />
<i-svg-circle-exclamation v-if="modalAlertState.type === 'error'" />
<i-svg-triangle-exclamation
v-if="modalAlertState.type === 'warning'"
/>
<h2 class="basic-modal-alert__title">
{{ modalAlertState.title }}
</h2>
<CloseButton
class="basic-modal-alert__close-button"
data-testid="basic-modal-alert-close-button"
@click.prevent="close"
/>
</CardHeader>
<CardBody class="basic-modal-alert__body">
{{ modalAlertState.body }}
</CardBody>
</Card>
</BaseModal>
</transition>
</template>

<style lang="postcss" scoped>
.basic-modal-alert {
@apply bg-white w-full max-w-xl;

svg {
@apply h-5 w-5 mr-2;
}

&.info svg {
@apply fill-blue-500;
}

&.success svg {
@apply fill-green-500;
}

&.error svg {
@apply fill-red-500;
}

&.warning svg {
@apply fill-yellow-500;
}

& .basic-modal-alert__header {
@apply flex flex-row items-center;
}

& .basic-modal-alert__title,
& .basic-modal-alert__body {
@apply flex flex-1;
}

& .basic-modal-alert__title {
@apply text-lg font-medium py-2;
@apply flex items-center;
}

& .basic-modal-alert__body {
@apply justify-center m-8;
}
}
</style>
11 changes: 5 additions & 6 deletions components/blocks/cards/SignUpCard/SignUpCard.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { type Ref, ref } from 'vue'
import { sentenceCase } from 'lib/helpers'
import BaseInput from 'elements/inputs/BaseInput/BaseInput.vue'
import HideShowPassword from 'elements/buttons/HideShowPassword/HideShowPassword.vue'
import BaseButton from 'elements/buttons/BaseButton/BaseButton.vue'
Expand Down Expand Up @@ -98,12 +99,10 @@ async function signup() {
},
{
onError: (val: any) => {
errorText.value = `Error(s): ${(
Object.values(val.error.errors) as string[]
).reduce(
(accumulator: string, val: string) => `${accumulator} ${val}`,
'',
)}`
const errors = Object.values(val.error?.errors) as string[][]
errorText.value = `Error(s): ${errors
.map((errorType) => errorType.map(sentenceCase))
.join(', ')}`
showErrorsText.value = true
},
onOkay: () => {
Expand Down
1 change: 1 addition & 0 deletions composables/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { default as useConfirmAccount } from './useConfirmAccount'
export { default as useGetLeaderboardDetail } from './useGetLeaderboardDetail'
export { default as useGetUserDetail } from './useGetUserDetail'
export { default as useLoginUser } from './useLoginUser'
Expand Down
20 changes: 20 additions & 0 deletions composables/api/useConfirmAccount/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { ApiResponse, optionalParameters, useApi } from 'composables/useApi'
import { Account } from 'lib/api/Account'

export async function useConfirmAccount(
confirmationToken: string,
opts: optionalParameters<void> = {},
): Promise<ApiResponse<void>> {
const { onError, onOkay } = opts

const account = new Account({
baseUrl: useRuntimeConfig().public.BACKEND_BASE_URL,
})

return await useApi<void>(
async () => await account.confirmUpdate(confirmationToken),
{ onError, onOkay },
)
}

export default useConfirmAccount
24 changes: 24 additions & 0 deletions composables/api/useConfirmAccount/useConfirmAccount.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useConfirmAccount } from '.'

const mockSuccessAccountConfirmation = vi.fn(() =>
Promise.resolve({ ok: true }),
)

describe('useConfirmUser', () => {
describe('when everything is successful', () => {
const confirmationCode = '123'

it('creates a PUT request to confirm the account', async () => {
vi.mock('lib/api/Account', () => ({
Account: function Account() {
this.confirmUpdate = mockSuccessAccountConfirmation
},
}))

await useConfirmAccount(confirmationCode)

expect(mockSuccessAccountConfirmation).toBeCalledTimes(1)
expect(mockSuccessAccountConfirmation).toBeCalledWith(confirmationCode)
})
})
})
4 changes: 2 additions & 2 deletions composables/api/useGetLeaderboardDetail/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { ApiResponse, optionalParameters, useApi } from 'composables/useApi'
import { Leaderboards } from 'lib/api/Leaderboards'
import type { LeaderboardViewModel } from 'lib/api/data-contracts'

export const useGetLeaderboardDetail = async (
export async function useGetLeaderboardDetail(
leaderboardSlug: string,
opts: optionalParameters<LeaderboardViewModel> = {},
): Promise<ApiResponse<LeaderboardViewModel>> => {
): Promise<ApiResponse<LeaderboardViewModel>> {
const { onError, onOkay } = opts
const responseData = ref<LeaderboardViewModel>({
categories: [],
Expand Down
4 changes: 2 additions & 2 deletions composables/api/useGetUserDetail/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { ApiResponse, optionalParameters, useApi } from 'composables/useApi'
import { Users } from 'lib/api/Users'
import type { UserViewModel } from 'lib/api/data-contracts'

export const useGetUserDetail = async (
export async function useGetUserDetail(
userId: string,
opts: optionalParameters<UserViewModel> = {},
): Promise<ApiResponse<UserViewModel>> => {
): Promise<ApiResponse<UserViewModel>> {
const { onError, onOkay } = opts
const responseData = ref<UserViewModel>({
id: '',
Expand Down
4 changes: 2 additions & 2 deletions composables/api/useLoginUser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import type {
UserViewModel,
} from 'lib/api/data-contracts'

export const useLoginUser = async (
export async function useLoginUser(
requestData: LoginRequest,
opts: optionalParameters<UserViewModel> = {},
): Promise<ApiResponse<LoginResponse>> => {
): Promise<ApiResponse<LoginResponse>> {
const { onError, onOkay } = opts
const authToken = useSessionToken()
const currentUser = useCurrentUser()
Expand Down
2 changes: 1 addition & 1 deletion composables/api/useLogoutUser/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useCurrentUser } from 'composables/useCurrentUser'
import { useSessionToken } from 'composables/useSessionToken'

export const useLogoutUser = (): void => {
export function useLogoutUser(): void {
const authToken = useSessionToken()
const currentUser = useCurrentUser()

Expand Down
4 changes: 2 additions & 2 deletions composables/api/useRegisterUser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { ApiResponse, optionalParameters, useApi } from 'composables/useApi'
import { Account } from 'lib/api/Account'
import type { RegisterRequest, UserViewModel } from 'lib/api/data-contracts'

export const useRegisterUser = async (
export async function useRegisterUser(
requestData: RegisterRequest,
opts: optionalParameters<UserViewModel> = {},
): Promise<ApiResponse<UserViewModel>> => {
): Promise<ApiResponse<UserViewModel>> {
const { onError, onOkay } = opts
const responseData = ref<UserViewModel>({
id: '',
Expand Down
4 changes: 2 additions & 2 deletions composables/useApi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ export interface optionalParameters<T> {
* @param {optionalParameters<T>} [opts = {}] - optional parameters. `onOkay`: a `function` to call when the API request succeeds. `responseData`: a `ref` to set the data to when the API request succeeds. If not passed it, a ref is created on a successful API call
* @returns {Promise<ApiResponse<T>>} returns an `ApiResponse` object, but the `data` property is not guaranteed to be present
*/
export const useApi = async <T>(
export async function useApi<T>(
apiRequest: () => Promise<HttpResponse<T, void | ProblemDetails>>,
opts: optionalParameters<T> = {},
): Promise<ApiResponse<T>> => {
): Promise<ApiResponse<T>> {
const responseError = ref<ProblemDetails | null | void>(null)
const responseErrors = ref<ValidationProblemDetails | null | void>(null)
const responseLoading = ref(true)
Expand Down
5 changes: 3 additions & 2 deletions composables/useCurrentUser.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { UserViewModel } from 'lib/api/data-contracts'

export const useCurrentUser = () =>
useState<UserViewModel>('current_user', () => ({
export function useCurrentUser() {
return useState<UserViewModel>('current_user', () => ({
id: '',
username: '',
}))
}

export default useCurrentUser
Loading