Skip to content

Commit f90cf02

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

File tree

6 files changed

+184
-115
lines changed

6 files changed

+184
-115
lines changed

src/components/NcUserStatusIcon/NcUserStatusIcon.vue

Lines changed: 67 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -29,127 +29,85 @@ This component displays a user status icon.
2929
```
3030
</docs>
3131

32-
<template>
33-
<span v-if="activeStatus"
34-
class="user-status-icon"
35-
:class="{
36-
'user-status-icon--invisible': ['invisible', 'offline'].includes(status),
37-
}"
38-
role="img"
39-
:aria-hidden="ariaHidden"
40-
:aria-label="ariaLabel"
41-
v-html="activeSvg" /> <!-- eslint-disable-line vue/no-v-html -->
42-
</template>
43-
44-
<script>
32+
<script setup lang="ts">
4533
import axios from '@nextcloud/axios'
4634
import { generateOcsUrl } from '@nextcloud/router'
4735
import { getCapabilities } from '@nextcloud/capabilities'
36+
import { computed, watch } from 'vue'
37+
import { getUserStatusText } from '../../utils/UserStatus.ts'
38+
import { t } from '../../l10n.js'
4839
4940
import onlineSvg from '../../assets/status-icons/user-status-online.svg?raw'
5041
import awaySvg from '../../assets/status-icons/user-status-away.svg?raw'
5142
import dndSvg from '../../assets/status-icons/user-status-dnd.svg?raw'
5243
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,
44+
import logger from '../../utils/logger.ts'
45+
46+
const props = withDefaults(defineProps<{
47+
/**
48+
* Set the user id to fetch the status
49+
*/
50+
user?: string
51+
52+
/**
53+
* Set the `aria-hidden` attribute
54+
*/
55+
ariaHidden?: boolean | 'true' | 'false'
56+
}>(), {
57+
user: undefined,
58+
ariaHidden: false,
59+
})
60+
61+
/**
62+
* The user preloaded user status.
63+
*/
64+
const status = defineModel<'online' | 'away' | 'busy' | 'dnd' | 'invisible' | 'offline'>('status', { default: undefined })
65+
const isInvisible = computed(() => status.value && ['invisible', 'offline'].includes(status.value))
66+
67+
/**
68+
* Aria label to set on the element (will be set when ariaHidden is not set)
69+
*/
70+
const ariaLabel = computed(() => (
71+
status.value && (!props.ariaHidden || props.ariaHidden === 'false')
72+
? t('User status: {status}', { status: getUserStatusText(status.value) })
73+
: undefined
74+
))
75+
76+
watch(() => props.user, async (user) => {
77+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
78+
if (!status.value && user && (getCapabilities() as any)?.user_status?.enabled) {
79+
try {
80+
const { data } = await axios.get(generateOcsUrl('/apps/user_status/api/v1/statuses/{user}', { user }))
81+
status.value = data.ocs?.data?.status
82+
} catch (error) {
83+
logger.debug('Error while fetching user status', { error })
10584
}
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-
},
85+
}
86+
}, { immediate: true })
87+
88+
const matchSvg = {
89+
online: onlineSvg,
90+
away: awaySvg,
91+
busy: awaySvg,
92+
dnd: dndSvg,
93+
invisible: invisibleSvg,
94+
offline: invisibleSvg,
15095
}
96+
const activeSvg = computed(() => status.value && matchSvg[status.value])
15197
</script>
15298

99+
<template>
100+
<span v-if="status"
101+
class="user-status-icon"
102+
:class="{
103+
'user-status-icon--invisible': isInvisible,
104+
}"
105+
:aria-hidden="!ariaLabel || undefined"
106+
:aria-label
107+
role="img"
108+
v-html="activeSvg" /> <!-- eslint-disable-line vue/no-v-html -->
109+
</template>
110+
153111
<style lang="scss" scoped>
154112
.user-status-icon {
155113
display: flex;

src/components/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,5 +81,5 @@ 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.ts'
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'
8585
export { default as NcVNodes } from './NcVNodes/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)