fix(communication): display project chat state and refresh room visibility
This commit is contained in:
@@ -81,7 +81,17 @@ test("disabling in the project form blocks list, provision, reads and writes unt
|
|||||||
})
|
})
|
||||||
await app.register(communicationRoutes)
|
await app.register(communicationRoutes)
|
||||||
await app.register(resourceRoutes)
|
await app.register(resourceRoutes)
|
||||||
const update = (chatEnabled: unknown, headers = { "x-manager": "true" }) => app.inject({ method: "PUT", url: "/resource/projects/1", headers, payload: { chatEnabled } })
|
// The project editor loads the complete record and sends it back, including dates and other fields.
|
||||||
|
const update = async (chatEnabled: unknown, headers = { "x-manager": "true" }) => {
|
||||||
|
const loaded = await app.inject("/resource/projects/1/true")
|
||||||
|
assert.equal(loaded.statusCode, 200, loaded.body)
|
||||||
|
const result = await app.inject({ method: "PUT", url: "/resource/projects/1", headers, payload: { ...loaded.json(), chatEnabled } })
|
||||||
|
if (result.statusCode === 200) {
|
||||||
|
assert.equal(result.json().chatEnabled, chatEnabled)
|
||||||
|
assert.equal((await app.inject("/resource/projects/1/true")).json().chatEnabled, chatEnabled)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
const provision = await app.inject({ method: "POST", url: "/communication/chat/project-rooms/1/provision" })
|
const provision = await app.inject({ method: "POST", url: "/communication/chat/project-rooms/1/provision" })
|
||||||
assert.equal(provision.statusCode, 200, provision.body)
|
assert.equal(provision.statusCode, 200, provision.body)
|
||||||
const roomId = provision.json().id
|
const roomId = provision.json().id
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({ row: { type: Object, required: true } })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span v-if="row.chatEnabled === true" class="text-primary">Ja</span>
|
||||||
|
<span v-else-if="row.chatEnabled === false" class="text-rose-600">Nein</span>
|
||||||
|
<span v-else class="text-gray-500">–</span>
|
||||||
|
</template>
|
||||||
@@ -99,6 +99,9 @@ async function refreshLists() {
|
|||||||
const scope = scopeVersion
|
const scope = scopeVersion
|
||||||
await Promise.all([loadRoomLists(scope), loadUnread(scope), loadCollaboration(scope)])
|
await Promise.all([loadRoomLists(scope), loadUnread(scope), loadCollaboration(scope)])
|
||||||
}
|
}
|
||||||
|
function refreshVisibleLists() {
|
||||||
|
if (!disposed && !document.hidden && !loading.value && hasSession.value) void refreshLists()
|
||||||
|
}
|
||||||
|
|
||||||
async function loadRoomPreferences(scope = scopeVersion) {
|
async function loadRoomPreferences(scope = scopeVersion) {
|
||||||
if (!hasSession.value || !currentScope(scope)) return
|
if (!hasSession.value || !currentScope(scope)) return
|
||||||
@@ -260,9 +263,15 @@ watch(() => route.query.room, () => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
mounted = true; void initialize()
|
mounted = true; void initialize()
|
||||||
pollTimer = window.setInterval(pollMessages, 2500)
|
pollTimer = window.setInterval(pollMessages, 2500)
|
||||||
listTimer = window.setInterval(() => { if (!document.hidden && !loading.value) void refreshLists() }, 15000)
|
listTimer = window.setInterval(refreshVisibleLists, 15000)
|
||||||
|
window.addEventListener('focus', refreshVisibleLists)
|
||||||
|
document.addEventListener('visibilitychange', refreshVisibleLists)
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disposed = true; ++scopeVersion; ++roomVersion; clearInterval(pollTimer); clearInterval(listTimer)
|
||||||
|
window.removeEventListener('focus', refreshVisibleLists)
|
||||||
|
document.removeEventListener('visibilitychange', refreshVisibleLists)
|
||||||
})
|
})
|
||||||
onBeforeUnmount(() => { disposed = true; ++scopeVersion; ++roomVersion; clearInterval(pollTimer); clearInterval(listTimer) })
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import plant from "~/components/columnRenderings/plant.vue"
|
|||||||
import vendor from "~/components/columnRenderings/vendor.vue"
|
import vendor from "~/components/columnRenderings/vendor.vue"
|
||||||
import product from "~/components/columnRenderings/product.vue"
|
import product from "~/components/columnRenderings/product.vue"
|
||||||
import active from "~/components/columnRenderings/active.vue"
|
import active from "~/components/columnRenderings/active.vue"
|
||||||
|
import projectChatEnabled from "~/components/columnRenderings/projectChatEnabled.vue"
|
||||||
import sellingPrice from "~/components/columnRenderings/sellingPrice.vue";
|
import sellingPrice from "~/components/columnRenderings/sellingPrice.vue";
|
||||||
import unit from "~/components/columnRenderings/unit.vue";
|
import unit from "~/components/columnRenderings/unit.vue";
|
||||||
import isCompany from "~/components/columnRenderings/isCompany.vue"
|
import isCompany from "~/components/columnRenderings/isCompany.vue"
|
||||||
@@ -1721,7 +1722,7 @@ export const useDataStore = defineStore('data', () => {
|
|||||||
key: "chatEnabled",
|
key: "chatEnabled",
|
||||||
label: "Projektchat aktiviert",
|
label: "Projektchat aktiviert",
|
||||||
inputType: "bool",
|
inputType: "bool",
|
||||||
component: active,
|
component: projectChatEnabled,
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
disabledFunction: () => {
|
disabledFunction: () => {
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ function setup(api = fixture(), query = {}) {
|
|||||||
const auth = vue.reactive({ user: { id: 'user-1' }, activeTenant: '1' })
|
const auth = vue.reactive({ user: { id: 'user-1' }, activeTenant: '1' })
|
||||||
const route = vue.reactive({ query })
|
const route = vue.reactive({ query })
|
||||||
const calls = [], toasts = [], timers = new Map()
|
const calls = [], toasts = [], timers = new Map()
|
||||||
|
const listeners = new Map()
|
||||||
|
const events = {
|
||||||
|
addEventListener: (event, handler) => listeners.set(event, handler),
|
||||||
|
removeEventListener: event => listeners.delete(event),
|
||||||
|
}
|
||||||
|
const document = { hidden: false, ...events }
|
||||||
let mount, unmount, timerId = 0
|
let mount, unmount, timerId = 0
|
||||||
const scope = vue.effectScope()
|
const scope = vue.effectScope()
|
||||||
const context = vm.createContext({
|
const context = vm.createContext({
|
||||||
@@ -44,11 +50,11 @@ function setup(api = fixture(), query = {}) {
|
|||||||
useRouter: () => ({ replace: async location => { route.query = location.query } }),
|
useRouter: () => ({ replace: async location => { route.query = location.query } }),
|
||||||
useNuxtApp: () => ({ $api: (url, options) => { calls.push({ url, options }); return api(url, options) } }),
|
useNuxtApp: () => ({ $api: (url, options) => { calls.push({ url, options }); return api(url, options) } }),
|
||||||
onMounted: handler => { mount = handler }, onBeforeUnmount: handler => { unmount = handler },
|
onMounted: handler => { mount = handler }, onBeforeUnmount: handler => { unmount = handler },
|
||||||
window: { setInterval: handler => { timers.set(++timerId, handler); return timerId } },
|
window: { ...events, setInterval: handler => { timers.set(++timerId, handler); return timerId } },
|
||||||
clearInterval: id => timers.delete(id), document: { hidden: false },
|
clearInterval: id => timers.delete(id), document,
|
||||||
})
|
})
|
||||||
scope.run(() => vm.runInContext(source, context))
|
scope.run(() => vm.runInContext(source, context))
|
||||||
return { auth, route, calls, toasts, timers, mount, close: () => { unmount(); scope.stop() }, run: code => vm.runInContext(code, context) }
|
return { auth, route, calls, toasts, timers, listeners, document, mount, close: () => { unmount(); scope.stop() }, run: code => vm.runInContext(code, context) }
|
||||||
}
|
}
|
||||||
|
|
||||||
test('only member rooms enter normal chat, with separate keys, groups and unread counts', async t => {
|
test('only member rooms enter normal chat, with separate keys, groups and unread counts', async t => {
|
||||||
@@ -235,6 +241,7 @@ test('unmount cancels both polling timers and ignores pending lists', async () =
|
|||||||
pending.resolve({ rooms: [sharedRoom], invitations: [] })
|
pending.resolve({ rooms: [sharedRoom], invitations: [] })
|
||||||
await settle()
|
await settle()
|
||||||
assert.equal(ui.timers.size, 0)
|
assert.equal(ui.timers.size, 0)
|
||||||
|
assert.equal(ui.listeners.size, 0)
|
||||||
assert.equal(ui.run('collaborationRooms.value.length'), 0)
|
assert.equal(ui.run('collaborationRooms.value.length'), 0)
|
||||||
assert.equal(ui.run('messages.value.length'), 0)
|
assert.equal(ui.run('messages.value.length'), 0)
|
||||||
})
|
})
|
||||||
@@ -327,3 +334,25 @@ test('removing an active project from the permitted room list clears its message
|
|||||||
assert.equal(ui.run('messages.value.length'), 0)
|
assert.equal(ui.run('messages.value.length'), 0)
|
||||||
assert.equal(ui.run('members.value.length'), 0)
|
assert.equal(ui.run('members.value.length'), 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('returning to chat immediately reflects a disabled or re-enabled project without waiting for polling', async t => {
|
||||||
|
let disabled = false
|
||||||
|
const ui = setup(fixture(url => url.endsWith('/project-rooms') ? { rooms: disabled ? [] : [{ key: 'project_1', exists: true, type: 'project' }] } : undefined), { room: 'project_1' })
|
||||||
|
t.after(ui.close)
|
||||||
|
ui.mount()
|
||||||
|
await settle()
|
||||||
|
disabled = true
|
||||||
|
ui.document.hidden = true
|
||||||
|
ui.listeners.get('visibilitychange')()
|
||||||
|
await settle()
|
||||||
|
assert.equal(ui.run('activeRoom.value.key'), 'project_1')
|
||||||
|
ui.document.hidden = false
|
||||||
|
ui.listeners.get('visibilitychange')()
|
||||||
|
await settle()
|
||||||
|
assert.equal(ui.run('activeRoom.value'), null)
|
||||||
|
assert.equal(ui.run('messages.value.length'), 0)
|
||||||
|
disabled = false
|
||||||
|
ui.listeners.get('focus')()
|
||||||
|
await settle()
|
||||||
|
assert.equal(ui.run('groupedRooms.value.find(group => group.label === "Projekte").rooms[0].key'), 'project_1')
|
||||||
|
})
|
||||||
|
|||||||
62
frontend/tests/project-chat-setting.test.cjs
Normal file
62
frontend/tests/project-chat-setting.test.cjs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const vue = require('vue')
|
||||||
|
const { renderToString } = require('@vue/server-renderer')
|
||||||
|
const { parse, compileScript } = require('@vue/compiler-sfc')
|
||||||
|
|
||||||
|
function component(filename, globals = {}) {
|
||||||
|
const { descriptor } = parse(fs.readFileSync(filename, 'utf8'), { filename })
|
||||||
|
const compiled = compileScript(descriptor, { id: filename, inlineTemplate: true }).content
|
||||||
|
const source = ts.transpileModule(compiled, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText
|
||||||
|
const context = vm.createContext({ exports: {}, require, ...globals })
|
||||||
|
vm.runInContext(source, context)
|
||||||
|
return context.exports.default
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the configured renderer, so accidentally wiring the unrelated "active" renderer fails this test.
|
||||||
|
const storeSource = fs.readFileSync(`${__dirname}/../stores/data.js`, 'utf8')
|
||||||
|
const store = ts.createSourceFile('data.js', storeSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
|
||||||
|
let chatColumn
|
||||||
|
function visit(node) {
|
||||||
|
if (ts.isObjectLiteralExpression(node) && node.properties.some(property =>
|
||||||
|
ts.isPropertyAssignment(property) && property.name.getText(store) === 'key' && property.initializer.getText(store) === '"chatEnabled"')) chatColumn = node
|
||||||
|
ts.forEachChild(node, visit)
|
||||||
|
}
|
||||||
|
visit(store)
|
||||||
|
const rendererName = chatColumn.properties.find(property => property.name?.getText(store) === 'component').initializer.getText(store)
|
||||||
|
const rendererImport = store.statements.find(statement => ts.isImportDeclaration(statement) && statement.importClause?.name?.text === rendererName)
|
||||||
|
const renderer = component(path.resolve(__dirname, '..', rendererImport.moduleSpecifier.text.replace(/^~\//, '')))
|
||||||
|
const column = vm.runInNewContext(`(${chatColumn.getText(store)})`, { [rendererName]: renderer })
|
||||||
|
|
||||||
|
test('project list renders the actual chat setting, independently of an unrelated active flag', async () => {
|
||||||
|
for (const [row, expected] of [
|
||||||
|
[{ chatEnabled: true }, 'Ja'],
|
||||||
|
[{ chatEnabled: true, active: false }, 'Ja'],
|
||||||
|
[{ chatEnabled: false, active: true }, 'Nein'],
|
||||||
|
[{ chatEnabled: false }, 'Nein'],
|
||||||
|
[{ active: true }, '–'],
|
||||||
|
]) {
|
||||||
|
const html = await renderToString(vue.createSSRApp(column.component, { row }))
|
||||||
|
assert.match(html, new RegExp(`>${expected}</span>`), JSON.stringify(row))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('project information shows Ja and Nein after loading the saved setting', async () => {
|
||||||
|
const information = component(`${__dirname}/../components/EntityShowSubInformation.vue`, {
|
||||||
|
useDataStore: () => ({ dataTypes: { projects: { templateColumns: [column] } } }),
|
||||||
|
useTempStore: () => ({}), useRouter: () => ({}),
|
||||||
|
})
|
||||||
|
for (const chatEnabled of [true, false]) {
|
||||||
|
const app = vue.createSSRApp(information, { item: { id: 1, chatEnabled }, topLevelType: 'projects', platform: 'default' })
|
||||||
|
app.component('UCard', { setup: (_, { slots }) => () => vue.h('section', slots.default?.()) })
|
||||||
|
app.component('UAlert', { render: () => null })
|
||||||
|
app.component('TelephonyCallButton', { render: () => null })
|
||||||
|
const html = await renderToString(app)
|
||||||
|
assert.match(html, /Projektchat aktiviert/)
|
||||||
|
assert.match(html, new RegExp(`>${chatEnabled ? 'Ja' : 'Nein'}</span>`))
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user