Repository navigation
feat: add favorite functionality for teams #2963
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
Changes from all commits
14862bd
ee7d5ff
e311ed4
8ff4842
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| /** | ||
| * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors | ||
| * SPDX-License-Identifier: AGPL-3.0-or-later | ||
| */ | ||
|
|
||
| namespace OCA\Circles\Controller; | ||
|
|
||
| use OCA\Circles\Service\TeamFavoriteService; | ||
| use OCP\AppFramework\Http\Attribute\NoAdminRequired; | ||
| use OCP\AppFramework\Http\DataResponse; | ||
| use OCP\AppFramework\OCSController; | ||
| use OCP\IRequest; | ||
|
|
||
| class TeamFavoritesController extends OCSController { | ||
| public function __construct( | ||
| string $appName, | ||
| IRequest $request, | ||
| private readonly TeamFavoriteService $teamFavoriteService, | ||
| ) { | ||
| parent::__construct($appName, $request); | ||
| } | ||
|
|
||
| #[NoAdminRequired] | ||
| public function getFavorites(): DataResponse { | ||
| return new DataResponse([ | ||
| 'circleIds' => $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<string> $circleIds | ||
| * @param list<string> $expectedCircleIds | ||
| */ | ||
| #[NoAdminRequired] | ||
| public function reorder(array $circleIds, array $expectedCircleIds): DataResponse { | ||
| return new DataResponse([ | ||
| 'circleIds' => $this->teamFavoriteService->reorder($circleIds, $expectedCircleIds), | ||
| ]); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| /** | ||
| * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors | ||
| * SPDX-License-Identifier: AGPL-3.0-or-later | ||
| */ | ||
|
|
||
| namespace OCA\Circles\Entity; | ||
|
|
||
| use DateTime; | ||
| use DateTimeZone; | ||
| use OCP\AppFramework\ORM\Attribute\Column; | ||
| use OCP\AppFramework\ORM\Attribute\Entity as EntityAttribute; | ||
| use OCP\AppFramework\ORM\Attribute\Id; | ||
| use OCP\DB\Schema\ColumnType; | ||
|
|
||
| #[EntityAttribute(name: 'circles_team_favorites')] | ||
| final class TeamFavorite { | ||
| #[Id] | ||
| #[Column(name: 'id', type: ColumnType::Integer)] | ||
| public ?int $id = null; | ||
|
|
||
| #[Column(name: 'user_id', type: ColumnType::String, length: 255)] | ||
| public string $userId = ''; | ||
|
|
||
| #[Column(name: 'circle_id', type: ColumnType::String, length: 32)] | ||
| public string $circleId = ''; | ||
|
|
||
| #[Column(name: 'position', type: ColumnType::Integer)] | ||
| public int $position = 0; | ||
|
|
||
| #[Column(name: 'created', type: ColumnType::Datetime)] | ||
| public DateTime $created; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we really need this column?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Alternatively we can replace the incremental id with a snowflake id which will contain the creation date.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, you’re right. The creation timestamp is not used anywhere, so I’ll remove this column in a follow-up PR. A snowflake ID would make sense if we needed the creation timestamp. Since the feature does not use that information, I think keeping the generated integer ID and removing created is the simpler option. |
||
|
|
||
| public function __construct() { | ||
| $this->created = new DateTime('now', new DateTimeZone('UTC')); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| <?php | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: naming convention for migrations is |
||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| /** | ||
| * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors | ||
| * SPDX-License-Identifier: AGPL-3.0-or-later | ||
| */ | ||
|
|
||
| namespace OCA\Circles\Migration; | ||
|
|
||
| use Closure; | ||
| use OCP\DB\ISchemaWrapper; | ||
| use OCP\DB\Schema\ColumnType; | ||
| use OCP\DB\Schema\SchemaException; | ||
| use OCP\Migration\IOutput; | ||
| use OCP\Migration\SimpleMigrationStep; | ||
|
|
||
| class Version20260922120000 extends SimpleMigrationStep { | ||
| /** | ||
| * @param IOutput $output | ||
| * @param Closure $schemaClosure The closure returns a schema wrapper. | ||
| * @param array $options | ||
| * | ||
| * @throws SchemaException | ||
| */ | ||
| public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { | ||
| /** @var ISchemaWrapper $schema */ | ||
| $schema = $schemaClosure(); | ||
|
|
||
| if (!$schema->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; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| /** | ||
| * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors | ||
| * SPDX-License-Identifier: AGPL-3.0-or-later | ||
| */ | ||
|
|
||
| namespace OCA\Circles\Repository; | ||
|
|
||
| use OCA\Circles\Entity\TeamFavorite; | ||
| use OCP\AppFramework\ORM\Repository; | ||
|
|
||
| /** | ||
| * @extends Repository<TeamFavorite> | ||
| */ | ||
| class TeamFavoriteRepository extends Repository { | ||
| public const string entityClass = TeamFavorite::class; | ||
|
|
||
| /** | ||
| * @return list<string> | ||
| */ | ||
| public function getCircleIds(string $userId, bool $accessibleOnly = false): array { | ||
| $qb = $this->connection->getQueryBuilder(); | ||
| $qb->selectDistinct(['favorite.circle_id', 'favorite.position']) | ||
| ->from($this->getTableName(), 'favorite') | ||
| ->where($qb->expr()->eq('favorite.user_id', $qb->createNamedParameter($userId))); | ||
|
|
||
| if ($accessibleOnly) { | ||
| $expr = $qb->expr(); | ||
| $qb->innerJoin('favorite', 'circles_member', 'user_member', | ||
| $expr->eq('user_member.user_id', 'favorite.user_id')); | ||
| $qb->leftJoin('user_member', 'circles_membership', 'membership', $expr->andX( | ||
| $expr->eq('membership.single_id', 'user_member.single_id'), | ||
| $expr->eq('membership.circle_id', 'favorite.circle_id'), | ||
| )); | ||
| $qb->leftJoin('membership', 'circles_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'), | ||
| )); | ||
| } | ||
|
|
||
| $qb->orderBy('favorite.position', 'ASC') | ||
| ->addOrderBy('favorite.circle_id', 'ASC'); | ||
|
|
||
| $result = $qb->executeQuery(); | ||
| $circleIds = []; | ||
| while ($row = $result->fetch()) { | ||
| $circleIds[] = $row['circle_id']; | ||
| } | ||
| $result->closeCursor(); | ||
|
|
||
| return $circleIds; | ||
| } | ||
|
|
||
| public function add(string $userId, string $circleId): void { | ||
| $last = iterator_to_array($this->findBy( | ||
| ['userId' => $userId], | ||
| ['position' => \SortDirection::Descending], | ||
| 1, | ||
| ), false); | ||
|
|
||
| $favorite = new TeamFavorite(); | ||
| $favorite->userId = $userId; | ||
| $favorite->circleId = $circleId; | ||
| $favorite->position = $last === [] ? 0 : $last[0]->position + 1; | ||
| $this->insert($favorite); | ||
| } | ||
|
|
||
| public function remove(string $userId, string $circleId): void { | ||
| $this->deleteBy(['userId' => $userId, 'circleId' => $circleId]); | ||
| } | ||
|
|
||
| /** | ||
| * @param list<string> $circleIds | ||
| */ | ||
| public function replaceOrder(string $userId, array $circleIds): void { | ||
| $positions = array_flip($circleIds); | ||
| $this->connection->beginTransaction(); | ||
| try { | ||
| foreach ($this->findBy(['userId' => $userId]) as $favorite) { | ||
| if (!isset($positions[$favorite->circleId])) { | ||
| continue; | ||
| } | ||
| $favorite->position = $positions[$favorite->circleId]; | ||
| $this->update($favorite); | ||
| } | ||
| $this->connection->commit(); | ||
| } catch (\Throwable $exception) { | ||
| $this->connection->rollBack(); | ||
| throw $exception; | ||
| } | ||
| } | ||
|
|
||
| public function removeCircle(string $circleId): void { | ||
| $this->deleteBy(['circleId' => $circleId]); | ||
| } | ||
|
|
||
| public function removeUser(string $userId): void { | ||
| $this->deleteBy(['userId' => $userId]); | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.