Skip to content

refactor(NcUserStatusIcon): migrate component to Typescript #6820

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 62 additions & 101 deletions src/components/NcUserStatusIcon/NcUserStatusIcon.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,124 +30,85 @@ This component displays a user status icon.
</docs>

<template>
<span v-if="activeStatus"
<span v-if="status"
class="user-status-icon"
:class="{
'user-status-icon--invisible': ['invisible', 'offline'].includes(status),
'user-status-icon--invisible': isInvisible,
}"
:aria-hidden="!ariaLabel || undefined"
:aria-label
role="img"
:aria-hidden="ariaHidden"
:aria-label="ariaLabel"
v-html="activeSvg" /> <!-- eslint-disable-line vue/no-v-html -->
</template>

<script>
<script setup lang="ts">
import axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'
import { getCapabilities } from '@nextcloud/capabilities'
import { computed, watch } from 'vue'
import { getUserStatusText } from '../../utils/UserStatus.ts'
import { t } from '../../l10n.js'

import onlineSvg from '../../assets/status-icons/user-status-online.svg?raw'
import awaySvg from '../../assets/status-icons/user-status-away.svg?raw'
import dndSvg from '../../assets/status-icons/user-status-dnd.svg?raw'
import invisibleSvg from '../../assets/status-icons/user-status-invisible.svg?raw'

import { getUserStatusText } from '../../utils/UserStatus.ts'
import { t } from '../../l10n.js'

export default {
name: 'NcUserStatusIcon',

props: {
/**
* Set the user id to fetch the status
*/
user: {
type: String,
default: null,
},

/**
* Set the status
*
* @type {'online' | 'away' | 'busy' | 'dnd' | 'invisible' | 'offline'}
*/
status: {
type: String,
default: null,
validator: (value) => [
'online',
'away',
'busy',
'dnd',
'invisible',
'offline',
].includes(value),
},

/**
* Set the `aria-hidden` attribute
*
* @type {'true' | 'false'}
*/
ariaHidden: {
type: String,
default: null,
validator: (value) => [
'true',
'false',
].includes(value),
},
},

data() {
return {
fetchedUserStatus: null,
import logger from '../../utils/logger.ts'

const props = withDefaults(defineProps<{
/**
* Set the user id to fetch the status
*/
user?: string,

/**
* The user preloaded user status.
*/
status?: 'online' | 'away' | 'busy' | 'dnd' | 'invisible' | 'offline'

/**
* Set the `aria-hidden` attribute
*/
ariaHidden?: boolean | 'true' | 'false'
}>(), {
user: undefined,
status: undefined,
ariaHidden: false,
})

const status = defineModel<typeof props.status>('status', { default: null })
const isInvisible = computed(() => status.value && ['invisible', 'offline'].includes(status.value))

watch(() => props.user, async (user) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!status.value && user && (getCapabilities() as any)?.user_status?.enabled) {
try {
const { data } = await axios.get(generateOcsUrl('/apps/user_status/api/v1/statuses/{user}', { user }))
status.value = data.ocs?.data?.status
} catch (error) {
logger.debug('Error while fetching user status', { error })
}
},

computed: {
activeStatus() {
return this.status ?? this.fetchedUserStatus
},

activeSvg() {
const matchSvg = {
online: onlineSvg,
away: awaySvg,
busy: awaySvg,
dnd: dndSvg,
invisible: invisibleSvg,
offline: invisibleSvg,
}
return matchSvg[this.activeStatus] ?? null
},

ariaLabel() {
if (this.ariaHidden === 'true') {
return null
}
return t('User status: {status}', { status: getUserStatusText(this.activeStatus) })
},
},

watch: {
user: {
immediate: true,
async handler(user) {
if (!user || !getCapabilities()?.user_status?.enabled) {
this.fetchedUserStatus = null
return
}
try {
const { data } = await axios.get(generateOcsUrl('/apps/user_status/api/v1/statuses/{user}', { user }))
this.fetchedUserStatus = data.ocs?.data?.status
} catch (error) {
this.fetchedUserStatus = null
}
},
},
},
}
}, { immediate: true })

/**
* Aria label to set on the element (will be set when ariaHidden is not set)
*/
const ariaLabel = computed(() => (
status.value && (!props.ariaHidden || props.ariaHidden === 'false')
? t('User status: {status}', { status: getUserStatusText(status.value) })
: undefined
))

const matchSvg = {
online: onlineSvg,
away: awaySvg,
busy: awaySvg,
dnd: dndSvg,
invisible: invisibleSvg,
offline: invisibleSvg,
}
const activeSvg = computed(() => status.value && matchSvg[status.value])
</script>

<style lang="scss" scoped>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

export type * from './NcUserStatusIcon.vue'
export { default } from './NcUserStatusIcon.vue'
2 changes: 1 addition & 1 deletion src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,4 @@ export { default as NcTextArea } from './NcTextArea/index.js'
export { default as NcTextField } from './NcTextField/index.js'
export { default as NcTimezonePicker } from './NcTimezonePicker/index.js'
export { default as NcUserBubble } from './NcUserBubble/index.js'
export { default as NcUserStatusIcon } from './NcUserStatusIcon/index.js'
export { default as NcUserStatusIcon } from './NcUserStatusIcon/index.ts'
5 changes: 0 additions & 5 deletions src/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,6 @@ declare const appVersion: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
declare const TRANSLATIONS: { locale: string, translations: any }[]

declare module '*?raw' {
const content: string
export default content
}

declare global {
interface Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
9 changes: 9 additions & 0 deletions src/vite.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

declare module '*?raw' {
const content: string
export default content
}
107 changes: 107 additions & 0 deletions tests/component/components/NcUserStatus.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { expect, test } from '@playwright/experimental-ct-vue'
import NcUserStatusIcon from '../../../src/components/NcUserStatusIcon/NcUserStatusIcon.vue'

test('fetches the user status', async ({ mount, page }) => {
await page.addScriptTag({ content: 'window._oc_capabilities = { user_status: { enabled: true } };' })
await page.route('**/ocs/v2.php/apps/user_status/api/v1/statuses/jdoe', (route) => {
route.fulfill({ status: 200, json: { ocs: { data: { status: 'online' } } } })
})

const component = await mount(NcUserStatusIcon, {
props: {
user: 'jdoe',
},
})

await expect(page.getByRole('img', { name: 'User status: online' })).toBeVisible()
await expect(component).not.toHaveAttribute('aria-hidden')
})

test('does not fetch the user status if preloaded', async ({ mount, page }) => {
await page.addScriptTag({ content: 'window._oc_capabilities = { user_status: { enabled: true } };' })
await page.route('**/ocs/v2.php/apps/user_status/api/v1/statuses/jdoe', (route) => {
route.abort()
throw new Error('Should not be accessed')
})

const component = await mount(NcUserStatusIcon, {
props: {
user: 'jdoe',
status: 'dnd',
},
})

await expect(page.getByRole('img', { name: 'User status: do not disturb' })).toBeVisible()
await expect(component).not.toHaveAttribute('aria-hidden')
})

test('explicitily make element visible for accessibility', async ({ mount, page }) => {
await page.addScriptTag({ content: 'window._oc_capabilities = { user_status: { enabled: true } };' })
await page.route('**/ocs/v2.php/apps/user_status/api/v1/statuses/jdoe', (route) => {
route.abort()
throw new Error('Should not be accessed')
})

const component = await mount(NcUserStatusIcon, {
props: {
user: 'jdoe',
status: 'dnd',
ariaHidden: false,
},
})

await expect(page.getByRole('img', { name: 'User status: do not disturb' })).toBeVisible()
await expect(component).not.toHaveAttribute('aria-hidden')
})

test('explicitily make element visible for accessibility (legacy)', async ({ mount, page }) => {
await page.addScriptTag({ content: 'window._oc_capabilities = { user_status: { enabled: true } };' })
await page.route('**/ocs/v2.php/apps/user_status/api/v1/statuses/jdoe', (route) => {
route.abort()
throw new Error('Should not be accessed')
})

const component = await mount(NcUserStatusIcon, {
props: {
user: 'jdoe',
status: 'dnd',
ariaHidden: 'false',
},
})

await expect(page.getByRole('img', { name: 'User status: do not disturb' })).toBeVisible()
await expect(component).not.toHaveAttribute('aria-hidden')
})

test('can hide the element from accessibility tree', async ({ mount, page }) => {
const component = await mount(NcUserStatusIcon, {
props: {
user: 'jdoe',
status: 'dnd',
ariaHidden: true,
},
})

await expect(page.locator('svg')).toBeVisible()
await expect(component).not.toHaveAttribute('aria-label')
await expect(component).toHaveAttribute('aria-hidden', 'true')
})

test('can hide the element from accessibility tree (legacy)', async ({ mount, page }) => {
const component = await mount(NcUserStatusIcon, {
props: {
user: 'jdoe',
status: 'dnd',
ariaHidden: 'true',
},
})

await expect(page.locator('svg')).toBeVisible()
await expect(component).not.toHaveAttribute('aria-label')
await expect(component).toHaveAttribute('aria-hidden', 'true')
})
Loading