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

Out of office #6194

Draft
wants to merge 15 commits into
base: master
Choose a base branch
from
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ReplyToMessageBody } from 'lib-common/generated/api-types/messaging'
import { ThreadReply } from 'lib-common/generated/api-types/messaging'
import { client } from '../../api-client'
import { createUrlSearchParams } from 'lib-common/api'
import { deserializeJsonGetReceiversResponse } from 'lib-common/generated/api-types/messaging'
import { deserializeJsonPagedCitizenMessageThreads } from 'lib-common/generated/api-types/messaging'
import { deserializeJsonThreadReply } from 'lib-common/generated/api-types/messaging'
import { uri } from 'lib-common/uri'
Expand Down Expand Up @@ -77,7 +78,7 @@ export async function getReceivers(): Promise<GetReceiversResponse> {
url: uri`/citizen/messages/receivers`.toString(),
method: 'GET'
})
return json
return deserializeJsonGetReceiversResponse(json)
}


Expand Down
31 changes: 20 additions & 11 deletions frontend/src/citizen-frontend/messages/MessageEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
FixedSpaceColumn,
FixedSpaceFlexWrap
} from 'lib-components/layout/flex-helpers'
import OutOfOfficeInfo from 'lib-components/messages/OutOfOfficeInfo'
import { ToggleableRecipient } from 'lib-components/messages/ToggleableRecipient'
import FileUpload, {
initialUploadStatus,
Expand Down Expand Up @@ -160,16 +161,18 @@ export default React.memo(function MessageEditor({
)

const validAccounts = useMemo(() => {
const accounts = receiverOptions.messageAccounts.filter(
(account) =>
selectedChildrenInSameUnit &&
message.children.some(
(childId) =>
receiverOptions.childrenToMessageAccounts[
childId
]?.newMessage.includes(account.id) ?? false
)
)
const accounts = receiverOptions.messageAccounts
.filter(
(account) =>
selectedChildrenInSameUnit &&
message.children.some(
(childId) =>
receiverOptions.childrenToMessageAccounts[
childId
]?.newMessage.includes(account.account.id) ?? false
)
)
.map((withPresence) => withPresence.account)
const [primary, secondary] = partition(accounts, isPrimaryRecipient)
return { primary, secondary }
}, [selectedChildrenInSameUnit, message.children, receiverOptions])
Expand Down Expand Up @@ -317,6 +320,11 @@ export default React.memo(function MessageEditor({
/>
</label>

<OutOfOfficeInfo
selectedAccountIds={recipients.primary.map((a) => a.id)}
accounts={receiverOptions.messageAccounts}
/>

{showSecondaryRecipientSelection && (
<>
<Gap size="xs" />
Expand All @@ -336,7 +344,8 @@ export default React.memo(function MessageEditor({
toggleable: true,
selected: recipients.secondary.some(
(acc) => acc.id === recipient.id
)
),
outOfOffice: null
}}
data-qa="secondary-recipient"
onToggleRecipient={(_, selected) =>
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/citizen-frontend/messages/MessagesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ export default React.memo(function MessagesPage() {
allowedAccounts={
receivers.getOrElse(null)?.childrenToMessageAccounts ?? {}
}
accountDetails={
receivers.getOrElse(null)?.messageAccounts ?? []
}
onThreadDeleted={() => {
onSelectedThreadDeleted()
addTimedNotification({
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/citizen-frontend/messages/ThreadView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import {
ChildMessageAccountAccess,
CitizenMessageThread,
Message,
MessageAccount
MessageAccount,
MessageAccountWithPresence
} from 'lib-common/generated/api-types/messaging'
import {
ChildId,
Expand Down Expand Up @@ -215,6 +216,7 @@ interface Props {
accountId: MessageAccountId
thread: CitizenMessageThread.Regular
allowedAccounts: Partial<Record<ChildId, ChildMessageAccountAccess>>
accountDetails: MessageAccountWithPresence[]
closeThread: () => void
onThreadDeleted: () => void
}
Expand All @@ -237,6 +239,7 @@ export default React.memo(
children
},
allowedAccounts,
accountDetails,
closeThread,
onThreadDeleted
}: Props,
Expand All @@ -246,7 +249,11 @@ export default React.memo(
const { setReplyContent, getReplyContent } = useContext(MessageContext)
const { addTimedNotification } = useContext(NotificationsContext)

const { onToggleRecipient, recipients } = useRecipients(messages, accountId)
const { onToggleRecipient, recipients } = useRecipients(
messages,
accountId,
accountDetails
)
const [replyEditorVisible, useReplyEditorVisible] = useBoolean(false)
const [confirmDelete, setConfirmDelete] = useState(false)

Expand Down
26 changes: 26 additions & 0 deletions frontend/src/e2e-test/pages/citizen/citizen-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
//
// SPDX-License-Identifier: LGPL-2.1-or-later

import FiniteDateRange from 'lib-common/finite-date-range'

import {
Element,
ElementCollection,
Expand Down Expand Up @@ -37,6 +39,7 @@ export default class CitizenMessagesPage {
#threadUrgent: Element
newMessageButton: Element
fileUpload: Element
#threadOutOfOfficeInfo: Element
constructor(private readonly page: Page) {
this.messageReplyContent = new TextInput(
page.findByDataQa('message-reply-content')
Expand All @@ -60,6 +63,9 @@ export default class CitizenMessagesPage {
.findByDataQa('urgent')
this.newMessageButton = page.findAllByDataQa('new-message-btn').first()
this.fileUpload = page.findByDataQa('upload-message-attachment')
this.#threadOutOfOfficeInfo = page
.findByDataQa('thread-reader')
.findByDataQa('out-of-office-info')
}

replyButtonTag = 'message-reply-editor-btn'
Expand Down Expand Up @@ -201,6 +207,15 @@ export default class CitizenMessagesPage {

await editor.sendMessage()
}

async assertThreadOutOfOffice(ooo: {
name: string
period: FiniteDateRange
}) {
await this.#threadOutOfOfficeInfo.assertText(
(t) => t.includes(ooo.name) && t.includes(ooo.period.format())
)
}
}

export class CitizenMessageEditor extends Element {
Expand All @@ -210,6 +225,7 @@ export class CitizenMessageEditor extends Element {
readonly title = new TextInput(this.findByDataQa('input-title'))
readonly content = new TextInput(this.findByDataQa('input-content'))
readonly #sendMessage = this.findByDataQa('send-message-btn')
readonly #outOfOfficeInfo = this.findByDataQa('out-of-office-info')

secondaryRecipient(name: string) {
return this.find(`[data-qa="secondary-recipient"]`, { hasText: name })
Expand Down Expand Up @@ -250,4 +266,14 @@ export class CitizenMessageEditor extends Element {
await this.#sendMessage.click()
await this.waitUntilHidden()
}

async assertOutOfOffice(ooo: { name: string; period: FiniteDateRange }) {
await this.#outOfOfficeInfo.assertText(
(t) => t.includes(ooo.name) && t.includes(ooo.period.format())
)
}

async assertNoOutOfOffice() {
await this.#outOfOfficeInfo.waitUntilHidden()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// SPDX-FileCopyrightText: 2017-2025 City of Espoo
//
// SPDX-License-Identifier: LGPL-2.1-or-later

import FiniteDateRange from 'lib-common/finite-date-range'
import LocalDate from 'lib-common/local-date'

import config from '../../../config'
import { Page, Element, DatePicker } from '../../../utils/page'

export class OutOfOfficePage {
#outOfOffice: Element
#outOfOfficeEditor: Element
#addButton: Element
#saveButton: Element
#editButton: Element
#removeButton: Element

constructor(page: Page) {
this.#outOfOffice = page.findByDataQa('out-of-office-page')
this.#outOfOfficeEditor = page.findByDataQa('out-of-office-editor')
this.#addButton = page.findByDataQa('add-out-of-office')
this.#saveButton = page.findByDataQa('save-out-of-office')
this.#editButton = page.findByDataQa('edit-out-of-office')
this.#removeButton = page.findByDataQa('remove-out-of-office')
}

static async open(page: Page) {
await page.goto(config.employeeUrl + '/out-of-office')
return new OutOfOfficePage(page)
}

async addOutOfOfficePeriod(startDate: LocalDate, endDate: LocalDate) {
await this.#addButton.click()

const startInput = new DatePicker(
this.#outOfOfficeEditor.findAll('input').first()
)
const endInput = new DatePicker(
this.#outOfOfficeEditor.findAll('input').last()
)

await startInput.fill(startDate)
await endInput.fill(endDate)
await this.#saveButton.click()
}

async editStartOfPeriod(newStartDate: LocalDate) {
await this.#editButton.click()
const startInput = new DatePicker(
this.#outOfOfficeEditor.findAll('input').first()
)
await startInput.fill(newStartDate)
await this.#saveButton.click()
}

async removeOutOfOfficePeriod() {
await this.#removeButton.click()
}

async assertNoPeriods() {
await this.#outOfOffice.assertText((t) =>
t.includes('Ei tulevia poissaoloja')
)
}

async assertPeriodExists(startDate: LocalDate, endDate: LocalDate) {
const periodText =
FiniteDateRange.tryCreate(startDate, endDate)?.format() ?? 'error'
await this.#outOfOffice.assertText(
(t) => t.includes(periodText) && !t.includes('Ei tulevia poissaoloja')
)
}

async assertPeriodDoesNotExist(startDate: LocalDate, endDate: LocalDate) {
const periodText =
FiniteDateRange.tryCreate(startDate, endDate)?.format() ?? 'error'
await this.#outOfOffice.assertText((t) => !t.includes(periodText))
}
}
123 changes: 123 additions & 0 deletions frontend/src/e2e-test/specs/7_messaging/out-of-office.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// SPDX-FileCopyrightText: 2017-2025 City of Espoo
//
// SPDX-License-Identifier: LGPL-2.1-or-later

import { DevEmployee } from 'e2e-test/generated/api-types'
import FiniteDateRange from 'lib-common/finite-date-range'
import LocalDate from 'lib-common/local-date'
import LocalTime from 'lib-common/local-time'

import config from '../../config'
import {
Fixture,
testAdult,
testCareArea,
testChild,
testDaycare
} from '../../dev-api/fixtures'
import {
createMessageAccounts,
resetServiceState
} from '../../generated/api-clients'
import CitizenMessagesPage from '../../pages/citizen/citizen-messages'
import { OutOfOfficePage } from '../../pages/employee/messages/out-of-office-page'
import { Page } from '../../utils/page'
import { employeeLogin, enduserLogin } from '../../utils/user'

let supervisor: DevEmployee

beforeEach(async () => {
await resetServiceState()
await Fixture.careArea(testCareArea).save()
const unit = await Fixture.daycare(testDaycare).save()
supervisor = await Fixture.employee().unitSupervisor(unit.id).save()

await Fixture.family({
guardian: testAdult,
children: [testChild]
}).save()

await Fixture.placement({
childId: testChild.id,
unitId: testDaycare.id,
startDate: LocalDate.of(2022, 1, 1),
endDate: LocalDate.of(2022, 12, 31)
}).save()

await createMessageAccounts()
})

describe('Out of Office', () => {
test('Out of Office flow', async () => {
// Employee sets an out of office period and edits it
const employeePage = await Page.open({
mockedTime: LocalDate.of(2022, 12, 1).toHelsinkiDateTime(
LocalTime.of(12, 0)
)
})
await employeeLogin(employeePage, supervisor)
const outOfOfficePage = await OutOfOfficePage.open(employeePage)
await outOfOfficePage.assertNoPeriods()

const startDate = LocalDate.of(2022, 12, 1)
const endDate = LocalDate.of(2022, 12, 7)
await outOfOfficePage.addOutOfOfficePeriod(startDate, endDate)
await outOfOfficePage.assertPeriodExists(startDate, endDate)

const newStartDate = LocalDate.of(2022, 12, 2)
await outOfOfficePage.editStartOfPeriod(newStartDate)
await outOfOfficePage.assertPeriodExists(newStartDate, endDate)
await outOfOfficePage.assertPeriodDoesNotExist(startDate, endDate)

// Citizen doesn't see the out of office period because it starts the next day
const citizenPage1 = await Page.open({
mockedTime: LocalDate.of(2022, 12, 1).toHelsinkiDateTime(
LocalTime.of(13, 0)
)
})
const { editor: editor1 } = await getCitizenMessageEditor(citizenPage1)
await editor1.assertNoOutOfOffice()

// Citizen sees the out of office period the next day
const citizenPage2 = await Page.open({
mockedTime: LocalDate.of(2022, 12, 2).toHelsinkiDateTime(
LocalTime.of(13, 0)
)
})
const { editor: editor2, messagesPage: messagesPage2 } =
await getCitizenMessageEditor(citizenPage2)
await editor2.assertOutOfOffice({
name: getSupervisorName(),
period: FiniteDateRange.tryCreate(newStartDate, endDate)!
})

// Citizen sends the message (so that a message that can be replied to is available)
await editor2.fillMessage('Test title', 'Test content')
await editor2.sendMessage()

// Reply editor shows the out of office period
await messagesPage2.openFirstThreadReplyEditor()
await messagesPage2.assertThreadOutOfOffice({
name: getSupervisorName(),
period: FiniteDateRange.tryCreate(newStartDate, endDate)!
})

// Employee removes the out of office period
await outOfOfficePage.removeOutOfOfficePeriod()
await outOfOfficePage.assertNoPeriods()
})
})

function getSupervisorName() {
return `${supervisor.lastName} ${supervisor.firstName}`
}

async function getCitizenMessageEditor(citizenPage: Page) {
await enduserLogin(citizenPage, testAdult)
await citizenPage.goto(config.enduserMessagesUrl)
const messagesPage = new CitizenMessagesPage(citizenPage)
const editor = await messagesPage.createNewMessage()
const supervisorName = getSupervisorName()
await editor.selectRecipients([supervisorName])
return { editor, messagesPage }
}
Loading
Loading