diff --git a/apps/profile/lib/Controller/ProfilePageController.php b/apps/profile/lib/Controller/ProfilePageController.php index 0a5ec251e38ce..69ed0652a4306 100644 --- a/apps/profile/lib/Controller/ProfilePageController.php +++ b/apps/profile/lib/Controller/ProfilePageController.php @@ -104,6 +104,7 @@ public function index(string $targetUserId): TemplateResponse { $this->eventDispatcher->dispatchTyped(new BeforeTemplateRenderedEvent($targetUserId)); + Util::addStyle('profile', 'main'); Util::addScript('profile', 'main'); return new TemplateResponse( diff --git a/apps/profile/src/components/ProfileSection.spec.ts b/apps/profile/src/components/ProfileSection.spec.ts new file mode 100644 index 0000000000000..67ca9617fccdb --- /dev/null +++ b/apps/profile/src/components/ProfileSection.spec.ts @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { render } from '@testing-library/vue' +import { expect, test, vi } from 'vitest' +import ProfileSection from './ProfileSection.vue' + +window.customElements.define('test-element', class extends HTMLElement { + user?: string + callback?: (user?: string) => void + + connectedCallback() { + this.callback?.(this.user) + } +}) + +test('can render section component', async () => { + const callback = vi.fn() + const result = render(ProfileSection, { + props: { + userId: 'testuser', + section: { + id: 'test-section', + order: 1, + tagName: 'test-element', + params: { + callback, + }, + }, + }, + }) + + // this basically covers everything we need to test: + // 1. The custom element is rendered + // 2. The custom params are passed to the custom element + // 3. The user id is passed to the custom element + expect(result.baseElement.querySelector('test-element')).toBeTruthy() + expect(callback).toHaveBeenCalledWith('testuser') +}) diff --git a/apps/profile/src/components/ProfileSection.vue b/apps/profile/src/components/ProfileSection.vue new file mode 100644 index 0000000000000..12c8f7f1ac885 --- /dev/null +++ b/apps/profile/src/components/ProfileSection.vue @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/apps/profile/src/main.ts b/apps/profile/src/main.ts index 594719deb8186..c10f2b34b987c 100644 --- a/apps/profile/src/main.ts +++ b/apps/profile/src/main.ts @@ -1,26 +1,16 @@ -/** +/* * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { getCSPNonce } from '@nextcloud/auth' -import Vue from 'vue' +import { createApp } from 'vue' import ProfileApp from './views/ProfileApp.vue' import ProfileSections from './services/ProfileSections.js' -__webpack_nonce__ = getCSPNonce() +import 'vite/modulepreload-polyfill' -if (!window.OCA) { - window.OCA = {} -} +window.OCA.Profile ??= {} +window.OCA.Profile.ProfileSections = new ProfileSections() -if (!window.OCA.Core) { - window.OCA.Core = {} -} -Object.assign(window.OCA.Core, { ProfileSections: new ProfileSections() }) - -const View = Vue.extend(ProfileApp) - -window.addEventListener('DOMContentLoaded', () => { - new View().$mount('#content') -}) +const app = createApp(ProfileApp) +app.mount('#content') diff --git a/apps/profile/src/services/ProfileSections.spec.ts b/apps/profile/src/services/ProfileSections.spec.ts new file mode 100644 index 0000000000000..4db3a12237f21 --- /dev/null +++ b/apps/profile/src/services/ProfileSections.spec.ts @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IProfileSection } from './ProfileSections.ts' + +import { expect, test, vi } from 'vitest' +import ProfileSections from './ProfileSections.ts' + +test('register profile section', () => { + const profileSection: IProfileSection = { + id: 'test-section', + order: 1, + tagName: 'test-element', + } + + const sections = new ProfileSections() + sections.registerSection(profileSection) + + expect(sections.getSections()).toHaveLength(1) + expect(sections.getSections()[0]).toBe(profileSection) +}) + +test('register profile section twice', () => { + const profileSection: IProfileSection = { + id: 'test-section', + order: 1, + tagName: 'test-element', + } + + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const sections = new ProfileSections() + sections.registerSection(profileSection) + sections.registerSection(profileSection) + + expect(spy).toHaveBeenCalled() + expect(sections.getSections()).toHaveLength(1) + expect(sections.getSections()[0]).toBe(profileSection) +}) diff --git a/apps/profile/src/services/ProfileSections.ts b/apps/profile/src/services/ProfileSections.ts index 73f96bbf01e37..50ff1f779ccd3 100644 --- a/apps/profile/src/services/ProfileSections.ts +++ b/apps/profile/src/services/ProfileSections.ts @@ -1,23 +1,52 @@ /** - * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { logger } from './logger.ts' + +export interface IProfileSection { + /** + * Unique identifier for the section + */ + id: string + /** + * The order in which the section should appear + */ + order: number + /** + * The custom element tag name to be used for this section + * + * The custom element must have been registered beforehand, + * and must have the a `user` property of type `string | undefined`. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Web_components + */ + tagName: string + /** + * Static parameters to be passed to the custom web component + */ + params?: Record +} + export default class ProfileSections { - _sections + #sections: Map constructor() { - this._sections = [] + this.#sections = new Map() } /** * @param section To be called to mount the section to the profile page */ - registerSection(section) { - this._sections.push(section) + registerSection(section: IProfileSection) { + if (this.#sections.has(section.id)) { + logger.warn(`Profile section with id '${section.id}' is already registered.`) + } + this.#sections.set(section.id, section) } getSections() { - return this._sections + return [...this.#sections.values()] } } diff --git a/apps/profile/src/services/logger.spec.ts b/apps/profile/src/services/logger.spec.ts new file mode 100644 index 0000000000000..5964ba5e68d9a --- /dev/null +++ b/apps/profile/src/services/logger.spec.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test, vi } from 'vitest' +import { logger } from './logger.ts' + +test('logger', () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + logger.warn('This is a warning message') + + expect(console.warn).toHaveBeenCalled() + expect(spy.mock.calls[0]![0]).toContain('profile') +}) diff --git a/apps/profile/src/services/logger.ts b/apps/profile/src/services/logger.ts new file mode 100644 index 0000000000000..a7ef8ecc5ca28 --- /dev/null +++ b/apps/profile/src/services/logger.ts @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getLoggerBuilder } from '@nextcloud/logger' + +export const logger = getLoggerBuilder() + .setApp('profile') + .detectLogLevel() + .build() diff --git a/apps/profile/src/views/ProfileApp.vue b/apps/profile/src/views/ProfileApp.vue index fdfc16ff92ceb..c71364c929b9c 100644 --- a/apps/profile/src/views/ProfileApp.vue +++ b/apps/profile/src/views/ProfileApp.vue @@ -3,6 +3,111 @@ - SPDX-License-Identifier: AGPL-3.0-or-later --> + + @@ -10,9 +115,9 @@ - {{ displayname || userId }} - · - {{ pronouns }} + {{ profileParameters.displayname || profileParameters.userId }} + · + {{ profileParameters.pronouns }} - {{ status.icon }} {{ status.message }} + {{ userStatus.icon }} {{ userStatus.message }} @@ -39,12 +144,12 @@ + :is-no-user="!profileParameters.isUserAvatarVisible" + @click.prevent.stop="openStatusModal" /> @@ -79,33 +184,31 @@ - - - {{ organisation }} • {{ role }} + + + {{ profileParameters.organisation }} • {{ profileParameters.role }} - + - {{ address }} + {{ profileParameters.address }} - - - {{ headline }} + + + {{ profileParameters.headline }} - + - - - + - -
{{ organisation }} • {{ role }}
{{ profileParameters.organisation }} • {{ profileParameters.role }}
- {{ address }} + {{ profileParameters.address }}