Skip to content
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
3 changes: 2 additions & 1 deletion dev/docker/opencloud.web.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"app-store",
"activities",
"preview",
"mail"
"mail",
"contacts"
]
}
10 changes: 10 additions & 0 deletions packages/web-app-contacts/l10n/.tx/config
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[main]
host = https://www.transifex.com

[o:opencloud-eu:p:opencloud-eu:r:web-contacts]
file_filter = locale/<lang>/app.po
minimum_perc = 0
resource_name = web-contacts
source_file = template.pot
source_lang = en
type = PO
1 change: 1 addition & 0 deletions packages/web-app-contacts/l10n/translations.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
21 changes: 21 additions & 0 deletions packages/web-app-contacts/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "web-app-contacts",
"version": "0.0.0",
"private": true,
"description": "OpenCloud groupware contacts app",
"license": "AGPL-3.0",
"devDependencies": {
"@opencloud-eu/web-test-helpers": "workspace:*"
},
"peerDependencies": {
"@opencloud-eu/design-system": "workspace:^",
"@opencloud-eu/web-client": "workspace:*",
"@opencloud-eu/web-pkg": "workspace:*",
"lodash-es": "4.17.23",
"pinia": "3.0.4",
"vue-concurrency": "^0.3.0",
"vue-router": "^4.2.5 || ^5.0.0",
"vue3-gettext": "^4.0.0-beta.1",
"zod": "^3.24.1"
}
}
7 changes: 7 additions & 0 deletions packages/web-app-contacts/src/LayoutContainer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<template>
<main id="contacts" class="h-full overflow-hidden p-4">
<router-view data-testid="contacts-router-view" />
</main>
</template>

<script setup lang="ts"></script>
1 change: 1 addition & 0 deletions packages/web-app-contacts/src/appid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const APPID = 'contacts'
77 changes: 77 additions & 0 deletions packages/web-app-contacts/src/components/AddressBooksList.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<template>
<div class="address-books-list px-1 flex flex-col">
<app-loading-spinner v-if="isLoading" />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The AppLoadingSpinner should really only be used once per app per view because it has a static ID.

Suggested change
<app-loading-spinner v-if="isLoading" />
<oc-spinner v-if="isLoading" :aria-label="$gettext('Loading address books')" />

<oc-list v-else>
<li v-for="addressBook in addressBooks" :key="addressBook.id" class="pb-1 px-2">
<oc-button
:class="[
'sidebar-address-book-item',
'relative',
'w-full',
'whitespace-nowrap',
'px-2',
'py-3',
'select-none',
'rounded-xl',
{ 'active overflow-hidden outline': currentAddressBook?.id === addressBook.id },
{
'hover:bg-role-surface-container-highest focus:bg-role-surface-container-highest':
currentAddressBook?.id !== addressBook.id
}
]"
:appearance="currentAddressBook?.id === addressBook.id ? 'filled' : 'raw-inverse'"
color-role="surface"
justify-content="left"
@click="onSelectAddressBook(addressBook)"
>
<div class="flex items-center justify-between w-full">
<div class="flex items-center truncate">
<oc-icon name="folder" class="mr-2" fill-type="line" />
<span class="truncate font-bold" v-text="addressBook.name" />
</div>
</div>
</oc-button>
</li>
</oc-list>
</div>
</template>

<script setup lang="ts">
import { useAddressBooksStore } from '../composables/piniaStores/addressbooks'
import { storeToRefs } from 'pinia'
import { AddressBook } from '../types'
import { useLoadAddressBooks } from '../composables/useLoadAddressbooks'
import { AppLoadingSpinner, useGroupwareAccountsStore } from '@opencloud-eu/web-pkg'
import { useLoadContacts } from '../composables/useLoadContacts'
import { unref } from 'vue'
import { useContactsStore } from '../composables/piniaStores/contacts'

const addressBooksStore = useAddressBooksStore()
const accountsStore = useGroupwareAccountsStore()
const { setCurrentContact } = useContactsStore()
const { currentAccount } = storeToRefs(accountsStore)
const { setCurrentAddressBook } = addressBooksStore
const { addressBooks, currentAddressBook } = storeToRefs(addressBooksStore)

const { isLoading } = useLoadAddressBooks()
const { loadContacts } = useLoadContacts()

const onSelectAddressBook = async (addressBook: AddressBook) => {
setCurrentAddressBook(addressBook)
setCurrentContact(null)
await loadContacts(unref(currentAccount).accountId, addressBook.id)
}
</script>

<style>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you scope this block? If not, please add it as CSS classes.

@reference '@opencloud-eu/design-system/tailwind';

@layer components {
.sidebar-address-book-item:is(.active) {
outline-color: var(--oc-role-surface-container-highest);
}
.sidebar-address-book-item:not(.active) {
color: var(--oc-role-on-surface-variant);
}
}
</style>
32 changes: 32 additions & 0 deletions packages/web-app-contacts/src/components/ContactsListItem.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<template>
<div class="contacts-list-item flex items-center gap-3 px-4 py-3">
<oc-avatar :user-name="avatarName" />
<div class="flex-1">
<div class="truncate font-bold text-lg" v-text="displayName" />
<div class="truncate text-sm text-role-on-surface-variant" v-text="displayEmail" />
</div>
</div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { Contact } from '../types'
import { getContactNameParts } from '../helpers/contactName'

const { contact } = defineProps<{
contact: Contact
}>()

const displayName = computed(() => {
const nameParts = getContactNameParts(contact)
return `${nameParts.givenName} ${nameParts.surname}`
})

const displayEmail = computed(() => {
return Object.values(contact.emails || {}).find((entry) => entry?.address)?.address
})

const avatarName = computed(() => {
return displayName.value || displayEmail.value
})
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { defineStore } from 'pinia'
import { computed, ref, unref } from 'vue'
import { AddressBook } from '../../types'
import { useRouteQuery } from '@opencloud-eu/web-pkg'

export const useAddressBooksStore = defineStore('addressBooks', () => {
const currentAddressBookIdQuery = useRouteQuery('addressBookId')

const addressBooks = ref<AddressBook[]>([])
const currentAddressBookId = ref<string>()

const currentAddressBook = computed(() =>
unref(addressBooks).find((addressBook) => addressBook.id === unref(currentAddressBookId))
)

const setAddressBooks = (data: AddressBook[]) => {
addressBooks.value = data
}

const upsertAddressBook = (data: AddressBook) => {
const existing = unref(addressBooks).find(({ id }) => id === data.id)
if (existing) {
Object.assign(existing, data)
return
}
unref(addressBooks).push(data)
}

const removeAddressBooks = (values: AddressBook[]) => {
addressBooks.value = unref(addressBooks).filter(
(addressBook) => !values.find(({ id }) => id === addressBook.id)
)

if (values.some((v) => v.id === unref(currentAddressBookId))) {
currentAddressBookId.value = null
currentAddressBookIdQuery.value = null
}
}

const setCurrentAddressBook = (data: AddressBook) => {
currentAddressBookId.value = data?.id
currentAddressBookIdQuery.value = data?.id
}

const updateAddressBookField = <T extends AddressBook>({
id,
field,
value
}: {
id: T['id']
field: keyof T
value: T[keyof T]
}) => {
const addressBook = unref(addressBooks).find((addressBook) => id === addressBook.id) as T
if (addressBook) {
addressBook[field] = value
}
}

const reset = () => {
addressBooks.value = []
currentAddressBookId.value = null
currentAddressBookIdQuery.value = null
}

return {
addressBooks,
currentAddressBook,
updateAddressBookField,
setAddressBooks,
upsertAddressBook,
removeAddressBooks,
setCurrentAddressBook,
reset
}
})

export type AddressBooksStore = ReturnType<typeof useAddressBooksStore>
78 changes: 78 additions & 0 deletions packages/web-app-contacts/src/composables/piniaStores/contacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { defineStore } from 'pinia'
import { computed, ref, unref } from 'vue'
import { Contact } from '../../types'
import { useRouteQuery } from '@opencloud-eu/web-pkg'

export const useContactsStore = defineStore('contacts', () => {
const currentContactIdQuery = useRouteQuery('contactId')

const contacts = ref<Contact[]>([])
const currentContactId = ref<string>()

const currentContact = computed(() =>
unref(contacts).find((contact) => contact.id === unref(currentContactId))
)

const setContacts = (data: Contact[]) => {
contacts.value = data
}

const upsertContact = (data: Contact) => {
const existing = unref(contacts).find(({ id }) => id === data.id)
if (existing) {
Object.assign(existing, data)
return
}
unref(contacts).push(data)
}

const removeContacts = (values: Contact[]) => {
contacts.value = unref(contacts).filter(
(contact) => !values.find(({ id }) => id === contact.id)
)

if (values.some((v) => v.id === unref(currentContactId))) {
currentContactId.value = null
currentContactIdQuery.value = null
}
}

const setCurrentContact = (data: Contact) => {
currentContactId.value = data?.id
currentContactIdQuery.value = data?.id
}

const updateContactField = <T extends Contact>({
id,
field,
value
}: {
id: T['id']
field: keyof T
value: T[keyof T]
}) => {
const contact = unref(contacts).find((contact) => id === contact.id) as T
if (contact) {
contact[field] = value
}
}

const reset = () => {
contacts.value = []
currentContactId.value = null
currentContactIdQuery.value = null
}

return {
contacts,
currentContact,
updateContactField,
setContacts,
upsertContact,
removeContacts,
setCurrentContact,
reset
}
})

export type ContactsStore = ReturnType<typeof useContactsStore>
42 changes: 42 additions & 0 deletions packages/web-app-contacts/src/composables/useLoadAddressbooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { computed } from 'vue'
import { useTask } from 'vue-concurrency'
import { useClientService, useConfigStore } from '@opencloud-eu/web-pkg'
import { useAddressBooksStore } from './piniaStores/addressbooks'
import { AddressBooksResponseSchema } from '../types'
import { urlJoin } from '@opencloud-eu/web-client'

let loadAddressBooksTask: ReturnType<typeof useTask> | null = null
const isLoading = computed(() => loadAddressBooksTask?.isRunning ?? false)

export const useLoadAddressBooks = () => {
const configStore = useConfigStore()
const clientService = useClientService()
const { setAddressBooks } = useAddressBooksStore()

if (!loadAddressBooksTask) {
loadAddressBooksTask = useTask(function* (signal, accountId: string) {
try {
const { data } = yield clientService.httpAuthenticated.get(
urlJoin(configStore.groupwareUrl, `accounts/${accountId}/addressbooks`)
)
const { addressbooks } = AddressBooksResponseSchema.parse(data)
setAddressBooks(addressbooks)
console.info('Loaded addressBooks:', addressbooks)
return addressbooks
} catch (e) {
console.error('Failed to load addressBooks:', e)
throw e
}
}).restartable()
}

const loadAddressBooks = (accountId: string) => {
return loadAddressBooksTask!.perform(accountId)
}

return {
loadAddressBooks,
loadAddressBooksTask,
isLoading
}
}
Loading