Skip to content

Commit 4f29f49

Browse files
committed
refactor(NcUserStatusIcon): migrate component to Typescript
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent 0799524 commit 4f29f49

File tree

6 files changed

+180
-107
lines changed

6 files changed

+180
-107
lines changed

src/components/NcUserStatusIcon/NcUserStatusIcon.vue

Lines changed: 62 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -30,124 +30,85 @@ This component displays a user status icon.
3030
</docs>
3131

3232
<template>
33-
<span v-if="activeStatus"
33+
<span v-if="status"
3434
class="user-status-icon"
3535
:class="{
36-
'user-status-icon--invisible': ['invisible', 'offline'].includes(status),
36+
'user-status-icon--invisible': isInvisible,
3737
}"
38+
:aria-hidden="!ariaLabel || undefined"
39+
:aria-label
3840
role="img"
39-
:aria-hidden="ariaHidden"
40-
:aria-label="ariaLabel"
4141
v-html="activeSvg" /> <!-- eslint-disable-line vue/no-v-html -->
4242
</template>
4343

44-
<script>
44+
<script setup lang="ts">
4545
import axios from '@nextcloud/axios'
4646
import { generateOcsUrl } from '@nextcloud/router'
4747
import { getCapabilities } from '@nextcloud/capabilities'
48+
import { computed, watch } from 'vue'
49+
import { getUserStatusText } from '../../utils/UserStatus.ts'
50+
import { t } from '../../l10n.js'
4851
4952
import onlineSvg from '../../assets/status-icons/user-status-online.svg?raw'
5053
import awaySvg from '../../assets/status-icons/user-status-away.svg?raw'
5154
import dndSvg from '../../assets/status-icons/user-status-dnd.svg?raw'
5255
import invisibleSvg from '../../assets/status-icons/user-status-invisible.svg?raw'
53-
54-
import { getUserStatusText } from '../../utils/UserStatus.ts'
55-
import { t } from '../../l10n.js'
56-
57-
export default {
58-
name: 'NcUserStatusIcon',
59-
60-
props: {
61-
/**
62-
* Set the user id to fetch the status
63-
*/
64-
user: {
65-
type: String,
66-
default: null,
67-
},
68-
69-
/**
70-
* Set the status
71-
*
72-
* @type {'online' | 'away' | 'busy' | 'dnd' | 'invisible' | 'offline'}
73-
*/
74-
status: {
75-
type: String,
76-
default: null,
77-
validator: (value) => [
78-
'online',
79-
'away',
80-
'busy',
81-
'dnd',
82-
'invisible',
83-
'offline',
84-
].includes(value),
85-
},
86-
87-
/**
88-
* Set the `aria-hidden` attribute
89-
*
90-
* @type {'true' | 'false'}
91-
*/
92-
ariaHidden: {
93-
type: String,
94-
default: null,
95-
validator: (value) => [
96-
'true',
97-
'false',
98-
].includes(value),
99-
},
100-
},
101-
102-
data() {
103-
return {
104-
fetchedUserStatus: null,
56+
import logger from '../../utils/logger.ts'
57+
58+
const props = withDefaults(defineProps<{
59+
/**
60+
* Set the user id to fetch the status
61+
*/
62+
user?: string,
63+
64+
/**
65+
* The user preloaded user status.
66+
*/
67+
status?: 'online' | 'away' | 'busy' | 'dnd' | 'invisible' | 'offline'
68+
69+
/**
70+
* Set the `aria-hidden` attribute
71+
*/
72+
ariaHidden?: boolean | 'true' | 'false'
73+
}>(), {
74+
user: undefined,
75+
status: undefined,
76+
ariaHidden: false,
77+
})
78+
79+
const status = defineModel<typeof props.status>('status', { default: null })
80+
const isInvisible = computed(() => status.value && ['invisible', 'offline'].includes(status.value))
81+
82+
watch(() => props.user, async (user) => {
83+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
84+
if (!status.value && user && (getCapabilities() as any)?.user_status?.enabled) {
85+
try {
86+
const { data } = await axios.get(generateOcsUrl('/apps/user_status/api/v1/statuses/{user}', { user }))
87+
status.value = data.ocs?.data?.status
88+
} catch (error) {
89+
logger.debug('Error while fetching user status', { error })
10590
}
106-
},
107-
108-
computed: {
109-
activeStatus() {
110-
return this.status ?? this.fetchedUserStatus
111-
},
112-
113-
activeSvg() {
114-
const matchSvg = {
115-
online: onlineSvg,
116-
away: awaySvg,
117-
busy: awaySvg,
118-
dnd: dndSvg,
119-
invisible: invisibleSvg,
120-
offline: invisibleSvg,
121-
}
122-
return matchSvg[this.activeStatus] ?? null
123-
},
124-
125-
ariaLabel() {
126-
if (this.ariaHidden === 'true') {
127-
return null
128-
}
129-
return t('User status: {status}', { status: getUserStatusText(this.activeStatus) })
130-
},
131-
},
132-
133-
watch: {
134-
user: {
135-
immediate: true,
136-
async handler(user) {
137-
if (!user || !getCapabilities()?.user_status?.enabled) {
138-
this.fetchedUserStatus = null
139-
return
140-
}
141-
try {
142-
const { data } = await axios.get(generateOcsUrl('/apps/user_status/api/v1/statuses/{user}', { user }))
143-
this.fetchedUserStatus = data.ocs?.data?.status
144-
} catch (error) {
145-
this.fetchedUserStatus = null
146-
}
147-
},
148-
},
149-
},
91+
}
92+
}, { immediate: true })
93+
94+
/**
95+
* Aria label to set on the element (will be set when ariaHidden is not set)
96+
*/
97+
const ariaLabel = computed(() => (
98+
status.value && (!props.ariaHidden || props.ariaHidden === 'false')
99+
? t('User status: {status}', { status: getUserStatusText(status.value) })
100+
: undefined
101+
))
102+
103+
const matchSvg = {
104+
online: onlineSvg,
105+
away: awaySvg,
106+
busy: awaySvg,
107+
dnd: dndSvg,
108+
invisible: invisibleSvg,
109+
offline: invisibleSvg,
150110
}
111+
const activeSvg = computed(() => status.value && matchSvg[status.value])
151112
</script>
152113

153114
<style lang="scss" scoped>

src/components/NcUserStatusIcon/index.js renamed to src/components/NcUserStatusIcon/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6+
export type * from './NcUserStatusIcon.vue'
67
export { default } from './NcUserStatusIcon.vue'

src/components/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,4 @@ export { default as NcTextArea } from './NcTextArea/index.js'
8181
export { default as NcTextField } from './NcTextField/index.js'
8282
export { default as NcTimezonePicker } from './NcTimezonePicker/index.js'
8383
export { default as NcUserBubble } from './NcUserBubble/index.js'
84-
export { default as NcUserStatusIcon } from './NcUserStatusIcon/index.js'
84+
export { default as NcUserStatusIcon } from './NcUserStatusIcon/index.ts'

src/globals.d.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,6 @@ declare const appVersion: string
1414
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1515
declare const TRANSLATIONS: { locale: string, translations: any }[]
1616

17-
declare module '*?raw' {
18-
const content: string
19-
export default content
20-
}
21-
2217
declare global {
2318
interface Window {
2419
// eslint-disable-next-line @typescript-eslint/no-explicit-any

src/vite.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
declare module '*?raw' {
7+
const content: string
8+
export default content
9+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { expect, test } from '@playwright/experimental-ct-vue'
7+
import NcUserStatusIcon from '../../../src/components/NcUserStatusIcon/NcUserStatusIcon.vue'
8+
9+
test('fetches the user status', async ({ mount, page }) => {
10+
await page.addScriptTag({ content: 'window._oc_capabilities = { user_status: { enabled: true } };' })
11+
await page.route('**/ocs/v2.php/apps/user_status/api/v1/statuses/jdoe', (route) => {
12+
route.fulfill({ status: 200, json: { ocs: { data: { status: 'online' } } } })
13+
})
14+
15+
const component = await mount(NcUserStatusIcon, {
16+
props: {
17+
user: 'jdoe',
18+
},
19+
})
20+
21+
await expect(page.getByRole('img', { name: 'User status: online' })).toBeVisible()
22+
await expect(component).not.toHaveAttribute('aria-hidden')
23+
})
24+
25+
test('does not fetch the user status if preloaded', async ({ mount, page }) => {
26+
await page.addScriptTag({ content: 'window._oc_capabilities = { user_status: { enabled: true } };' })
27+
await page.route('**/ocs/v2.php/apps/user_status/api/v1/statuses/jdoe', (route) => {
28+
route.abort()
29+
throw new Error('Should not be accessed')
30+
})
31+
32+
const component = await mount(NcUserStatusIcon, {
33+
props: {
34+
user: 'jdoe',
35+
status: 'dnd',
36+
},
37+
})
38+
39+
await expect(page.getByRole('img', { name: 'User status: do not disturb' })).toBeVisible()
40+
await expect(component).not.toHaveAttribute('aria-hidden')
41+
})
42+
43+
test('explicitily make element visible for accessibility', async ({ mount, page }) => {
44+
await page.addScriptTag({ content: 'window._oc_capabilities = { user_status: { enabled: true } };' })
45+
await page.route('**/ocs/v2.php/apps/user_status/api/v1/statuses/jdoe', (route) => {
46+
route.abort()
47+
throw new Error('Should not be accessed')
48+
})
49+
50+
const component = await mount(NcUserStatusIcon, {
51+
props: {
52+
user: 'jdoe',
53+
status: 'dnd',
54+
ariaHidden: false,
55+
},
56+
})
57+
58+
await expect(page.getByRole('img', { name: 'User status: do not disturb' })).toBeVisible()
59+
await expect(component).not.toHaveAttribute('aria-hidden')
60+
})
61+
62+
test('explicitily make element visible for accessibility (legacy)', async ({ mount, page }) => {
63+
await page.addScriptTag({ content: 'window._oc_capabilities = { user_status: { enabled: true } };' })
64+
await page.route('**/ocs/v2.php/apps/user_status/api/v1/statuses/jdoe', (route) => {
65+
route.abort()
66+
throw new Error('Should not be accessed')
67+
})
68+
69+
const component = await mount(NcUserStatusIcon, {
70+
props: {
71+
user: 'jdoe',
72+
status: 'dnd',
73+
ariaHidden: 'false',
74+
},
75+
})
76+
77+
await expect(page.getByRole('img', { name: 'User status: do not disturb' })).toBeVisible()
78+
await expect(component).not.toHaveAttribute('aria-hidden')
79+
})
80+
81+
test('can hide the element from accessibility tree', async ({ mount, page }) => {
82+
const component = await mount(NcUserStatusIcon, {
83+
props: {
84+
user: 'jdoe',
85+
status: 'dnd',
86+
ariaHidden: true,
87+
},
88+
})
89+
90+
await expect(page.locator('svg')).toBeVisible()
91+
await expect(component).not.toHaveAttribute('aria-label')
92+
await expect(component).toHaveAttribute('aria-hidden', 'true')
93+
})
94+
95+
test('can hide the element from accessibility tree (legacy)', async ({ mount, page }) => {
96+
const component = await mount(NcUserStatusIcon, {
97+
props: {
98+
user: 'jdoe',
99+
status: 'dnd',
100+
ariaHidden: 'true',
101+
},
102+
})
103+
104+
await expect(page.locator('svg')).toBeVisible()
105+
await expect(component).not.toHaveAttribute('aria-label')
106+
await expect(component).toHaveAttribute('aria-hidden', 'true')
107+
})

0 commit comments

Comments
 (0)