From 14862bd50229b59760459ea33e08a178ac4b5192 Mon Sep 17 00:00:00 2001 From: Fin Clausen Date: Mon, 28 Sep 2026 17:16:09 +0200 Subject: [PATCH 1/4] feat: add favorite functionality for teams Assisted-by: GitHub Copilot GPT-5.6 Terra Assisted-by: GitHub Copilot GPT-6 Astra Signed-off-by: Fin Clausen --- REUSE.toml | 6 + TESTING.md | 29 +++ appinfo/info.xml | 2 +- appinfo/routes.php | 3 + lib/Controller/TeamFavoritesController.php | 55 +++++ lib/Db/CircleRequest.php | 11 + lib/Db/CoreRequestBuilder.php | 7 + lib/Db/TeamFavoriteRequest.php | 128 ++++++++++++ lib/Listeners/UserDeleted.php | 3 + lib/Migration/Version20260922120000.php | 63 ++++++ lib/Service/TeamFavoriteService.php | 121 +++++++++++ src/teams/api.spec.ts | 30 ++- src/teams/api.ts | 32 ++- src/teams/components/TeamAvatar.vue | 49 ++++- src/teams/components/TeamCard.vue | 190 +++++++++++++++--- src/teams/components/TeamHeader.vue | 13 +- src/teams/store.spec.ts | 67 ++++++ src/teams/store.ts | 61 ++++++ src/teams/types.ts | 2 + src/teams/views/HomeView.spec.ts | 88 ++++++++ src/teams/views/HomeView.vue | 179 ++++++++++++++++- tests/unit/lib/Db/TeamFavoriteRequestTest.php | 122 +++++++++++ .../lib/Service/TeamFavoriteServiceTest.php | 101 ++++++++++ 23 files changed, 1315 insertions(+), 47 deletions(-) create mode 100644 lib/Controller/TeamFavoritesController.php create mode 100644 lib/Db/TeamFavoriteRequest.php create mode 100644 lib/Migration/Version20260922120000.php create mode 100644 lib/Service/TeamFavoriteService.php create mode 100644 src/teams/store.spec.ts create mode 100644 src/teams/views/HomeView.spec.ts create mode 100644 tests/unit/lib/Db/TeamFavoriteRequestTest.php create mode 100644 tests/unit/lib/Service/TeamFavoriteServiceTest.php diff --git a/REUSE.toml b/REUSE.toml index ff6137c23..16b5c82c6 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -5,6 +5,12 @@ SPDX-PackageName = "circles" SPDX-PackageSupplier = "Nextcloud " SPDX-PackageDownloadLocation = "https://github.com/nextcloud/circles" +[[annotations]] +path = ["src/teams/store.spec.ts", "src/teams/views/HomeView.spec.ts", "tests/unit/lib/Service/TeamFavoriteServiceTest.php", "tests/unit/lib/Db/TeamFavoriteRequestTest.php"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2026 Nextcloud GmbH and Nextcloud contributors" +SPDX-License-Identifier = "AGPL-3.0-or-later" + [[annotations]] path = ["l10n/**.js", "l10n/**.json", "composer.json", "composer.lock", "package.json", "package-lock.json", "tsconfig.json"] precedence = "aggregate" diff --git a/TESTING.md b/TESTING.md index 78603f042..801c6b6e3 100644 --- a/TESTING.md +++ b/TESTING.md @@ -12,6 +12,35 @@ The app has two layers of front-end tests: PHP tests (PHPUnit, Psalm) live under `tests/` and are documented separately. +## Favorite teams regression tests + +Run the focused frontend checks with: + +```bash +npm test -- --run src/teams/store.spec.ts src/teams/views/HomeView.spec.ts src/teams/api.spec.ts +``` + +These cover canonical ordering after removal/addition, overlapping mutations, +conflict recovery, and cancelled and accepted drops. +Dragging only changes a local preview; the server is updated on an accepted drop. +Reorder requests include both `circleIds` and `expectedCircleIds`; stale orders +return HTTP 409 and the client reloads the current state. + +The PHP service tests cover membership pruning, stale-order conflicts, idempotent +updates and lock release on failures: + +```bash +composer run test:unit -- --filter TeamFavorite +``` + +The standard PHP test bootstrap requires an installed Nextcloud test instance. +The database tests use transaction rollback and cover direct/inherited membership, +position gaps, user isolation, and preserving rows during reordering. Run these only +against a development/test instance. Fresh migrations and other database engines +still require verification in CI. Before a PR, +also check direct and inherited memberships, team/user deletion, two concurrent +browser tabs, and star appearance in light/dark themes. + ## Vitest (unit & component) ### Run diff --git a/appinfo/info.xml b/appinfo/info.xml index 08e2c7f2b..73b023b6c 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -12,7 +12,7 @@ Those groups of people can then be used by any other app for sharing purpose. ]]> - 36.0.0-dev.0 + 36.0.0-dev.1 AGPL-3.0-or-later Maxence Lange diff --git a/appinfo/routes.php b/appinfo/routes.php index ba43f0f51..5ae7dbd85 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -53,6 +53,9 @@ ['name' => 'TeamFolder#getLinkableTeamFolders', 'url' => '/teams/{circleId}/folder/linkable', 'verb' => 'GET'], ['name' => 'TeamFolder#linkTeamFolder', 'url' => '/teams/{circleId}/folder/link', 'verb' => 'POST'], ['name' => 'TeamFolder#updateTeamFolderQuota', 'url' => '/teams/{circleId}/folder/quota', 'verb' => 'PUT'], + ['name' => 'TeamFavorites#getFavorites', 'url' => '/teams/favorites', 'verb' => 'GET'], + ['name' => 'TeamFavorites#setFavorite', 'url' => '/teams/{circleId}/favorite', 'verb' => 'PUT'], + ['name' => 'TeamFavorites#reorder', 'url' => '/teams/favorites/order', 'verb' => 'PUT'], // Teams Dashboard widget endpoint ['name' => 'TeamsDashboard#getCompleteTeamsData', 'url' => '/teams/dashboard/widget', 'verb' => 'GET'], diff --git a/lib/Controller/TeamFavoritesController.php b/lib/Controller/TeamFavoritesController.php new file mode 100644 index 000000000..ced460565 --- /dev/null +++ b/lib/Controller/TeamFavoritesController.php @@ -0,0 +1,55 @@ + $this->teamFavoriteService->getFavoriteCircleIds(), + ]); + } + + #[NoAdminRequired] + public function setFavorite(string $circleId, bool $isFavorite): DataResponse { + $circleIds = $this->teamFavoriteService->setFavorite($circleId, $isFavorite); + + return new DataResponse([ + 'circleId' => $circleId, + 'isFavorite' => $isFavorite, + 'circleIds' => $circleIds, + ]); + } + + /** + * @param list $circleIds + * @param list $expectedCircleIds + */ + #[NoAdminRequired] + public function reorder(array $circleIds, array $expectedCircleIds): DataResponse { + return new DataResponse([ + 'circleIds' => $this->teamFavoriteService->reorder($circleIds, $expectedCircleIds), + ]); + } +} diff --git a/lib/Db/CircleRequest.php b/lib/Db/CircleRequest.php index 99436aba0..a0b9011d4 100644 --- a/lib/Db/CircleRequest.php +++ b/lib/Db/CircleRequest.php @@ -21,6 +21,8 @@ use OCA\Circles\Model\Member; use OCA\Circles\Model\Probes\CircleProbe; use OCA\Circles\Model\Probes\DataProbe; +use OCA\Circles\Service\ConfigService; +use OCA\Circles\Service\TimezoneService; use OCP\DB\QueryBuilder\IQueryBuilder; /** @@ -29,6 +31,14 @@ * @package OCA\Circles\Db */ class CircleRequest extends CircleRequestBuilder { + public function __construct( + private readonly TeamFavoriteRequest $teamFavoriteRequest, + TimezoneService $timezoneService, + ConfigService $configService, + ) { + parent::__construct($timezoneService, $configService); + } + /** * @param Circle $circle * @@ -523,6 +533,7 @@ public function delete(Circle $circle): void { $qb->limitToUniqueId($circle->getSingleId()); $qb->executeStatement(); + $this->teamFavoriteRequest->removeCircle($circle->getSingleId()); } /** diff --git a/lib/Db/CoreRequestBuilder.php b/lib/Db/CoreRequestBuilder.php index 685e58a3c..313730917 100644 --- a/lib/Db/CoreRequestBuilder.php +++ b/lib/Db/CoreRequestBuilder.php @@ -31,6 +31,7 @@ class CoreRequestBuilder { public const TABLE_CIRCLE = 'circles_circle'; public const TABLE_INVITATIONS = 'circles_invitations'; + public const TABLE_TEAM_FAVORITES = 'circles_team_favorites'; public const TABLE_MEMBER = 'circles_member'; public const TABLE_MEMBERSHIP = 'circles_membership'; public const TABLE_REMOTE = 'circles_remote'; @@ -62,6 +63,12 @@ class CoreRequestBuilder { 'created_by', 'created', ], + self::TABLE_TEAM_FAVORITES => [ + 'user_id', + 'circle_id', + 'position', + 'created', + ], self::TABLE_MEMBER => [ 'circle_id', 'member_id', diff --git a/lib/Db/TeamFavoriteRequest.php b/lib/Db/TeamFavoriteRequest.php new file mode 100644 index 000000000..ace812dc9 --- /dev/null +++ b/lib/Db/TeamFavoriteRequest.php @@ -0,0 +1,128 @@ + + */ + public function getCircleIds(string $userId, bool $accessibleOnly = false): array { + $qb = $this->getQueryBuilder(); + $qb->selectDistinct(['favorite.circle_id', 'favorite.position']) + ->from(self::TABLE_TEAM_FAVORITES, 'favorite') + ->where($qb->expr()->eq('favorite.user_id', $qb->createNamedParameter($userId))) + ->orderBy('favorite.position', 'ASC') + ->addOrderBy('favorite.circle_id', 'ASC'); + + if ($accessibleOnly) { + $expr = $qb->expr(); + $qb->innerJoin('favorite', self::TABLE_MEMBER, 'user_member', + $expr->eq('user_member.user_id', 'favorite.user_id')); + $qb->leftJoin('user_member', self::TABLE_MEMBERSHIP, 'membership', $expr->andX( + $expr->eq('membership.single_id', 'user_member.single_id'), + $expr->eq('membership.circle_id', 'favorite.circle_id'), + )); + $qb->leftJoin('membership', self::TABLE_MEMBER, 'inherited_member', $expr->andX( + $expr->eq('inherited_member.single_id', 'membership.inheritance_first'), + $expr->eq('inherited_member.circle_id', 'favorite.circle_id'), + )); + $qb->andWhere($expr->orX( + $expr->eq('user_member.circle_id', 'favorite.circle_id'), + $expr->isNotNull('inherited_member.single_id'), + )); + } + + $result = $qb->executeQuery(); + $circleIds = []; + while ($row = $result->fetch()) { + $circleIds[] = $row['circle_id']; + } + $result->closeCursor(); + + return $circleIds; + } + + public function add(string $userId, string $circleId): void { + $max = $this->getQueryBuilder(); + $max->select($max->func()->max('position')) + ->from(self::TABLE_TEAM_FAVORITES) + ->where($max->expr()->eq('user_id', $max->createNamedParameter($userId))); + $result = $max->executeQuery(); + $lastPosition = $result->fetchOne(); + $result->closeCursor(); + $position = $lastPosition === null || $lastPosition === false ? 0 : (int)$lastPosition + 1; + + $qb = $this->getQueryBuilder(); + $qb->insert(self::TABLE_TEAM_FAVORITES) + ->setValue('user_id', $qb->createNamedParameter($userId)) + ->setValue('circle_id', $qb->createNamedParameter($circleId)) + ->setValue('position', $qb->createNamedParameter($position, IQueryBuilder::PARAM_INT)) + ->setValue('created', $qb->createNamedParameter($this->timezoneService->getUTCDate())); + $qb->executeStatement(); + } + + public function remove(string $userId, string $circleId): void { + $qb = $this->getQueryBuilder(); + $qb->delete(self::TABLE_TEAM_FAVORITES) + ->where($qb->expr()->andX( + $qb->expr()->eq('user_id', $qb->createNamedParameter($userId)), + $qb->expr()->eq('circle_id', $qb->createNamedParameter($circleId)), + )); + $qb->executeStatement(); + } + + /** + * @param list $circleIds + */ + public function replaceOrder(string $userId, array $circleIds): void { + $this->db->beginTransaction(); + try { + foreach ($circleIds as $position => $circleId) { + $update = $this->getQueryBuilder(); + $update->update(self::TABLE_TEAM_FAVORITES) + ->set('position', $update->createNamedParameter($position, IQueryBuilder::PARAM_INT)) + ->where($update->expr()->eq('user_id', $update->createNamedParameter($userId))) + ->andWhere($update->expr()->eq('circle_id', $update->createNamedParameter($circleId))); + $update->executeStatement(); + } + $this->db->commit(); + } catch (\Throwable $exception) { + $this->db->rollBack(); + throw $exception; + } + } + + public function removeCircle(string $circleId): void { + $qb = $this->getQueryBuilder(); + $qb->delete(self::TABLE_TEAM_FAVORITES) + ->where($qb->expr()->eq('circle_id', $qb->createNamedParameter($circleId))); + $qb->executeStatement(); + } + + public function removeUser(string $userId): void { + $qb = $this->getQueryBuilder(); + $qb->delete(self::TABLE_TEAM_FAVORITES) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))); + $qb->executeStatement(); + } +} diff --git a/lib/Listeners/UserDeleted.php b/lib/Listeners/UserDeleted.php index 2cfee73e2..28f075065 100644 --- a/lib/Listeners/UserDeleted.php +++ b/lib/Listeners/UserDeleted.php @@ -10,6 +10,7 @@ namespace OCA\Circles\Listeners; use Exception; +use OCA\Circles\Db\TeamFavoriteRequest; use OCA\Circles\Service\SyncService; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; @@ -24,6 +25,7 @@ class UserDeleted implements IEventListener { */ public function __construct( private readonly SyncService $syncService, + private readonly TeamFavoriteRequest $teamFavoriteRequest, ) { } @@ -36,6 +38,7 @@ public function handle(Event $event): void { } $user = $event->getUser(); + $this->teamFavoriteRequest->removeUser($user->getUID()); try { $this->syncService->userDeleted($user->getUID()); diff --git a/lib/Migration/Version20260922120000.php b/lib/Migration/Version20260922120000.php new file mode 100644 index 000000000..a5c723f2b --- /dev/null +++ b/lib/Migration/Version20260922120000.php @@ -0,0 +1,63 @@ +hasTable('circles_team_favorites')) { + $table = $schema->createTable('circles_team_favorites'); + $table->addColumn('id', ColumnType::Integer, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 8, + 'unsigned' => true, + ]); + $table->addColumn('user_id', ColumnType::String, [ + 'length' => 255, + 'notnull' => true, + ]); + $table->addColumn('circle_id', ColumnType::String, [ + 'length' => 32, + 'notnull' => true, + ]); + $table->addColumn('position', ColumnType::Integer, [ + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('created', ColumnType::Datetime, [ + 'notnull' => true, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['user_id', 'circle_id']); + $table->addIndex(['user_id', 'position']); + $table->addIndex(['circle_id']); + } + + return $schema; + } +} diff --git a/lib/Service/TeamFavoriteService.php b/lib/Service/TeamFavoriteService.php new file mode 100644 index 000000000..315e1ca8d --- /dev/null +++ b/lib/Service/TeamFavoriteService.php @@ -0,0 +1,121 @@ + + */ + public function getFavoriteCircleIds(): array { + return $this->teamFavoriteRequest->getCircleIds($this->getCurrentUserId(), true); + } + + /** + * @return list + */ + public function setFavorite(string $circleId, bool $isFavorite): array { + $userId = $this->getCurrentUserId(); + $lock = $this->acquireLock($userId); + try { + $favoriteCircleIds = $this->pruneFavorites($userId); + if (!$isFavorite) { + $this->teamFavoriteRequest->remove($userId, $circleId); + } elseif (!in_array($circleId, $favoriteCircleIds, true)) { + try { + $this->permissionService->userMustBeMember($userId, $circleId); + } catch (InsufficientPermissionException $exception) { + throw new OCSException($exception->getMessage(), Http::STATUS_FORBIDDEN); + } + $this->teamFavoriteRequest->add($userId, $circleId); + } + return $this->teamFavoriteRequest->getCircleIds($userId, true); + } finally { + $this->lockingProvider->releaseLock($lock, ILockingProvider::LOCK_EXCLUSIVE); + } + } + + /** + * @param list $circleIds + * @param list $expectedCircleIds + * @return list + */ + public function reorder(array $circleIds, array $expectedCircleIds): array { + foreach ([$circleIds, $expectedCircleIds] as $ids) { + if (!array_is_list($ids) || array_filter($ids, 'is_string') !== $ids + || count($ids) !== count(array_unique($ids))) { + throw new OCSException('Invalid favorite team order.', Http::STATUS_BAD_REQUEST); + } + } + if (count($circleIds) !== count($expectedCircleIds) || array_diff($circleIds, $expectedCircleIds) !== []) { + throw new OCSException('Invalid favorite team order.', Http::STATUS_BAD_REQUEST); + } + $userId = $this->getCurrentUserId(); + $lock = $this->acquireLock($userId); + try { + $currentCircleIds = $this->pruneFavorites($userId); + if ($expectedCircleIds !== $currentCircleIds) { + throw new OCSException('Favorite teams have changed. Reload before reordering.', Http::STATUS_CONFLICT); + } + if ($circleIds !== $currentCircleIds) { + $this->teamFavoriteRequest->replaceOrder($userId, $circleIds); + } + return $this->teamFavoriteRequest->getCircleIds($userId, true); + } finally { + $this->lockingProvider->releaseLock($lock, ILockingProvider::LOCK_EXCLUSIVE); + } + } + + /** + * @return list + */ + private function pruneFavorites(string $userId): array { + $stored = $this->teamFavoriteRequest->getCircleIds($userId); + $accessible = $this->teamFavoriteRequest->getCircleIds($userId, true); + foreach (array_diff($stored, $accessible) as $circleId) { + $this->teamFavoriteRequest->remove($userId, $circleId); + } + return $accessible; + } + + private function acquireLock(string $userId): string { + $lock = 'circles:team-favorites:' . hash('sha256', $userId); + try { + $this->lockingProvider->acquireLock($lock, ILockingProvider::LOCK_EXCLUSIVE); + } catch (LockedException) { + throw new OCSException('Favorite teams are being updated. Please retry.', Http::STATUS_CONFLICT); + } + return $lock; + } + + private function getCurrentUserId(): string { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new OCSException('No authenticated user.', Http::STATUS_UNAUTHORIZED); + } + + return $user->getUID(); + } +} diff --git a/src/teams/api.spec.ts b/src/teams/api.spec.ts index 44e0609f2..66c46b94f 100644 --- a/src/teams/api.spec.ts +++ b/src/teams/api.spec.ts @@ -32,7 +32,7 @@ vi.mock('./team-page/services/collaborationAutocompletion.js', () => ({ getSuggestions: vi.fn(), })) -const { createTeam, fetchTeams } = await import('./api.ts') +const { createTeam, fetchTeams, setTeamFavorite, reorderFavoriteTeams } = await import('./api.ts') describe('fetchTeams', () => { it('drops circles the current user is not a member of', async () => { @@ -43,6 +43,7 @@ describe('fetchTeams', () => { vi.mocked(axios.get) .mockResolvedValueOnce({ data: { ocs: { data: circles } } }) .mockResolvedValueOnce({ data: { ocs: { data: [] } } }) + .mockResolvedValueOnce({ data: { ocs: { data: { circleIds: [] } } } }) const teams = await fetchTeams() @@ -55,6 +56,7 @@ describe('fetchTeams', () => { vi.mocked(axios.get) .mockResolvedValueOnce({ data: { ocs: {} } }) .mockResolvedValueOnce({ data: { ocs: { data: [] } } }) + .mockResolvedValueOnce({ data: { ocs: { data: { circleIds: [] } } } }) await expect(fetchTeams()).resolves.toEqual([]) }) @@ -85,3 +87,29 @@ describe('createTeam', () => { ) }) }) + +describe('favorite teams API', () => { + beforeEach(() => vi.resetAllMocks()) + + it('returns the canonical favorite list after a toggle', async () => { + vi.mocked(axios.put).mockResolvedValue({ data: { ocs: { data: { circleIds: ['A', 'B'] } } } }) + expect(await setTeamFavorite('B', true)).toEqual(['A', 'B']) + expect(axios.put).toHaveBeenCalledWith(expect.stringContaining('/favorite'), { isFavorite: true }) + }) + + it('includes the expected order and reads the canonical response', async () => { + vi.mocked(axios.put).mockResolvedValue({ data: { ocs: { data: { circleIds: ['B', 'A'] } } } }) + expect(await reorderFavoriteTeams(['B', 'A'], ['A', 'B'])).toEqual(['B', 'A']) + expect(axios.put).toHaveBeenCalledWith('/ocs/apps/circles/teams/favorites/order', { + circleIds: ['B', 'A'], + expectedCircleIds: ['A', 'B'], + }) + }) + + it('does not silently replace favorites with an empty list on a failed read', async () => { + vi.mocked(axios.get).mockResolvedValueOnce({ data: { ocs: { data: [] } } }) + .mockResolvedValueOnce({ data: { ocs: { data: [] } } }) + .mockRejectedValueOnce(new Error('favorites unavailable')) + await expect(fetchTeams()).rejects.toThrow('favorites unavailable') + }) +}) diff --git a/src/teams/api.ts b/src/teams/api.ts index 9de42fae6..318ecd9b5 100644 --- a/src/teams/api.ts +++ b/src/teams/api.ts @@ -140,9 +140,10 @@ function mapFullMember(raw: RawMember): Member { * description, member count, our role) with the members preview and resources. */ export async function fetchTeams(): Promise { - const [circlesRes, dashRes] = await Promise.allSettled([ + const [circlesRes, dashRes, favoritesRes] = await Promise.allSettled([ axios.get>(generateOcsUrl('apps/circles/circles') + '?limit=-1'), axios.get>(generateOcsUrl('apps/circles/teams/dashboard/widget') + '?limit=200&offset=0'), + axios.get>(generateOcsUrl('apps/circles/teams/favorites')), ]) // The team list is required; without it we have nothing to show. @@ -160,6 +161,11 @@ export async function fetchTeams(): Promise { logger.warn('Failed to load team dashboard previews', { error: dashRes.reason }) } const dashboardById = new Map(dashboard.map((team) => [team.singleId, team])) + if (favoritesRes.status === 'rejected') { + throw favoritesRes.reason + } + const favoriteCircleIds = favoritesRes.value.data.ocs.data.circleIds + const favoritePositions = new Map(favoriteCircleIds.map((circleId, position) => [circleId, position])) return circles.map((circle) => { const extra = dashboardById.get(circle.id) @@ -167,6 +173,8 @@ export async function fetchTeams(): Promise { id: circle.id, displayName: circle.displayName || circle.name, description: circle.description ?? '', + isFavorite: favoritePositions.has(circle.id), + favoritePosition: favoritePositions.get(circle.id) ?? null, memberCount: circle.population ?? extra?.members.length ?? 0, myRole: levelToRole(circle.initiator?.level), members: (extra?.members ?? []).map(mapPreviewMember), @@ -175,6 +183,28 @@ export async function fetchTeams(): Promise { }) } +/** + * Update a favorite and return the canonical order. + * + * @param teamId - Team identifier + * @param isFavorite - Desired favorite state + */ +export async function setTeamFavorite(teamId: string, isFavorite: boolean): Promise { + const response = await axios.put>(generateOcsUrl('apps/circles/teams/{circleId}/favorite', { circleId: teamId }), { isFavorite }) + return response.data.ocs.data.circleIds +} + +/** + * Save a new order only if the server still has the expected order. + * + * @param teamIds - Desired order + * @param expectedCircleIds - Previously loaded order + */ +export async function reorderFavoriteTeams(teamIds: string[], expectedCircleIds: string[]): Promise { + const response = await axios.put>(generateOcsUrl('apps/circles/teams/favorites/order'), { circleIds: teamIds, expectedCircleIds }) + return response.data.ocs.data.circleIds +} + /** * Fetch the full member list for a team (includes roles). * diff --git a/src/teams/components/TeamAvatar.vue b/src/teams/components/TeamAvatar.vue index 9e549999a..88a84010a 100644 --- a/src/teams/components/TeamAvatar.vue +++ b/src/teams/components/TeamAvatar.vue @@ -35,6 +35,7 @@ export function getAvatarUrl(circleId: string): string { + + diff --git a/src/teams/components/TeamCard.vue b/src/teams/components/TeamCard.vue index 4d05f3883..3609a61db 100644 --- a/src/teams/components/TeamCard.vue +++ b/src/teams/components/TeamCard.vue @@ -6,57 +6,143 @@