mirror of
https://github.com/chenasraf/nextcloud-pantry.git
synced 2026-05-18 01:28:57 +00:00
feat: notes wall
This commit is contained in:
179
lib/Controller/NotesWallController.php
Normal file
179
lib/Controller/NotesWallController.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Controller;
|
||||
|
||||
use OCA\Pantry\Exception\ForbiddenException;
|
||||
use OCA\Pantry\Exception\NotFoundException;
|
||||
use OCA\Pantry\ResponseDefinitions;
|
||||
use OCA\Pantry\Service\HouseAuthService;
|
||||
use OCA\Pantry\Service\NotesWallService;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUserSession;
|
||||
|
||||
/**
|
||||
* @psalm-import-type PantryNote from ResponseDefinitions
|
||||
* @psalm-import-type PantrySuccess from ResponseDefinitions
|
||||
*/
|
||||
final class NotesWallController extends OCSController {
|
||||
use TranslatesDomainExceptions;
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private NotesWallService $notes,
|
||||
private HouseAuthService $auth,
|
||||
private IUserSession $userSession,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all notes in a house
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param int<1, 500> $limit Maximum number of notes to return.
|
||||
* @param int<0, max> $offset Number of notes to skip.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, list<PantryNote>, array{}>
|
||||
*
|
||||
* 200: Notes returned
|
||||
*/
|
||||
#[ApiRoute(verb: 'GET', url: '/api/houses/{houseId}/notes')]
|
||||
#[NoAdminRequired]
|
||||
public function indexNotes(int $houseId, int $limit = 100, int $offset = 0): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $limit, $offset): DataResponse {
|
||||
$this->auth->requireMember($houseId, $this->requireUid());
|
||||
$all = $this->notes->listNotes($houseId);
|
||||
$sliced = array_slice($all, max(0, $offset), max(0, $limit));
|
||||
return new DataResponse(array_map(fn ($n) => $n->jsonSerialize(), $sliced));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a note
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param string $title Note title.
|
||||
* @param string|null $content Markdown content.
|
||||
* @param string|null $color Hex color (#RRGGBB).
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantryNote, array{}>
|
||||
*
|
||||
* 200: Note created
|
||||
*/
|
||||
#[ApiRoute(verb: 'POST', url: '/api/houses/{houseId}/notes')]
|
||||
#[NoAdminRequired]
|
||||
public function createNote(int $houseId, string $title, ?string $content = null, ?string $color = null): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $title, $content, $color): DataResponse {
|
||||
$uid = $this->requireUid();
|
||||
$this->auth->requireMember($houseId, $uid);
|
||||
$note = $this->notes->createNote($houseId, $uid, $title, $content, $color);
|
||||
return new DataResponse($note->jsonSerialize());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a note
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param int $noteId Note id.
|
||||
* @param string|null $title New title.
|
||||
* @param string|null $content New content (empty string clears).
|
||||
* @param string|null $color New color (empty string clears).
|
||||
* @param int|null $sortOrder New sort order.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantryNote, array{}>
|
||||
*
|
||||
* 200: Note updated
|
||||
*/
|
||||
#[ApiRoute(verb: 'PATCH', url: '/api/houses/{houseId}/notes/{noteId}')]
|
||||
#[NoAdminRequired]
|
||||
public function updateNote(int $houseId, int $noteId, ?string $title = null, ?string $content = null, ?string $color = null, ?int $sortOrder = null): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $noteId, $title, $content, $color, $sortOrder): DataResponse {
|
||||
$this->auth->requireMember($houseId, $this->requireUid());
|
||||
$existing = $this->notes->getNote($noteId);
|
||||
$this->assertInHouse($existing->getHouseId(), $houseId);
|
||||
$patch = [];
|
||||
if ($title !== null) {
|
||||
$patch['title'] = $title;
|
||||
}
|
||||
if ($content !== null) {
|
||||
$patch['content'] = $content;
|
||||
}
|
||||
if ($color !== null) {
|
||||
$patch['color'] = $color;
|
||||
}
|
||||
if ($sortOrder !== null) {
|
||||
$patch['sortOrder'] = $sortOrder;
|
||||
}
|
||||
$note = $this->notes->updateNote($noteId, $patch);
|
||||
return new DataResponse($note->jsonSerialize());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a note
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param int $noteId Note id.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantrySuccess, array{}>
|
||||
*
|
||||
* 200: Note deleted
|
||||
*/
|
||||
#[ApiRoute(verb: 'DELETE', url: '/api/houses/{houseId}/notes/{noteId}')]
|
||||
#[NoAdminRequired]
|
||||
public function deleteNote(int $houseId, int $noteId): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $noteId): DataResponse {
|
||||
$this->auth->requireMember($houseId, $this->requireUid());
|
||||
$existing = $this->notes->getNote($noteId);
|
||||
$this->assertInHouse($existing->getHouseId(), $houseId);
|
||||
$this->notes->deleteNote($noteId);
|
||||
return new DataResponse(['success' => true]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch reorder notes
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param list<array{id: int, sortOrder: int}> $items Reorder entries.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantrySuccess, array{}>
|
||||
*
|
||||
* 200: Notes reordered
|
||||
*/
|
||||
#[ApiRoute(verb: 'POST', url: '/api/houses/{houseId}/notes/reorder')]
|
||||
#[NoAdminRequired]
|
||||
public function reorderNotes(int $houseId, array $items = []): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $items): DataResponse {
|
||||
$this->auth->requireMember($houseId, $this->requireUid());
|
||||
$this->notes->reorderNotes($houseId, $items);
|
||||
return new DataResponse(['success' => true]);
|
||||
});
|
||||
}
|
||||
|
||||
private function requireUid(): string {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
throw new ForbiddenException('Not authenticated');
|
||||
}
|
||||
return $user->getUID();
|
||||
}
|
||||
|
||||
private function assertInHouse(int $entityHouseId, int $routeHouseId): void {
|
||||
if ($entityHouseId !== $routeHouseId) {
|
||||
throw new NotFoundException('Note does not belong to this house');
|
||||
}
|
||||
}
|
||||
}
|
||||
60
lib/Db/Note.php
Normal file
60
lib/Db/Note.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Db;
|
||||
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
|
||||
/**
|
||||
* @method int getHouseId()
|
||||
* @method void setHouseId(int $houseId)
|
||||
* @method string getTitle()
|
||||
* @method void setTitle(string $title)
|
||||
* @method string|null getContent()
|
||||
* @method void setContent(?string $content)
|
||||
* @method string|null getColor()
|
||||
* @method void setColor(?string $color)
|
||||
* @method string getCreatedBy()
|
||||
* @method void setCreatedBy(string $createdBy)
|
||||
* @method int getSortOrder()
|
||||
* @method void setSortOrder(int $sortOrder)
|
||||
* @method int getCreatedAt()
|
||||
* @method void setCreatedAt(int $createdAt)
|
||||
* @method int getUpdatedAt()
|
||||
* @method void setUpdatedAt(int $updatedAt)
|
||||
*/
|
||||
class Note extends Entity implements \JsonSerializable {
|
||||
protected int $houseId = 0;
|
||||
protected string $title = '';
|
||||
protected ?string $content = null;
|
||||
protected ?string $color = null;
|
||||
protected string $createdBy = '';
|
||||
protected int $sortOrder = 0;
|
||||
protected int $createdAt = 0;
|
||||
protected int $updatedAt = 0;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('houseId', 'integer');
|
||||
$this->addType('sortOrder', 'integer');
|
||||
$this->addType('createdAt', 'integer');
|
||||
$this->addType('updatedAt', 'integer');
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'houseId' => $this->houseId,
|
||||
'title' => $this->title,
|
||||
'content' => $this->content,
|
||||
'color' => $this->color,
|
||||
'createdBy' => $this->createdBy,
|
||||
'sortOrder' => $this->sortOrder,
|
||||
'createdAt' => $this->createdAt,
|
||||
'updatedAt' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
56
lib/Db/NoteMapper.php
Normal file
56
lib/Db/NoteMapper.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Db;
|
||||
|
||||
use OCA\Pantry\AppInfo\Application;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/**
|
||||
* @template-extends QBMapper<Note>
|
||||
*/
|
||||
class NoteMapper extends QBMapper {
|
||||
public function __construct(IDBConnection $db) {
|
||||
parent::__construct($db, Application::tableName('notes'), Note::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Note[]
|
||||
*/
|
||||
public function findByHouse(int $houseId): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('house_id', $qb->createNamedParameter($houseId, IQueryBuilder::PARAM_INT)))
|
||||
->orderBy('sort_order', 'ASC')
|
||||
->addOrderBy('created_at', 'DESC');
|
||||
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DoesNotExistException
|
||||
*/
|
||||
public function findById(int $id): Note {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
|
||||
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
public function deleteByHouse(int $houseId): void {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->delete($this->getTableName())
|
||||
->where($qb->expr()->eq('house_id', $qb->createNamedParameter($houseId, IQueryBuilder::PARAM_INT)));
|
||||
$qb->executeStatement();
|
||||
}
|
||||
}
|
||||
@@ -321,6 +321,50 @@ class Version1Date20260405000000 extends SimpleMigrationStep {
|
||||
$table->addIndex(['folder_id'], 'pantry_photos_folder_idx');
|
||||
}
|
||||
|
||||
// ---- pantry_notes ----
|
||||
$notesTable = Application::tableName('notes');
|
||||
if (!$schema->hasTable($notesTable)) {
|
||||
$table = $schema->createTable($notesTable);
|
||||
$table->addColumn('id', Types::BIGINT, [
|
||||
'autoincrement' => true,
|
||||
'notnull' => true,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->addColumn('house_id', Types::BIGINT, [
|
||||
'notnull' => true,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->addColumn('title', Types::STRING, [
|
||||
'notnull' => true,
|
||||
'length' => 255,
|
||||
]);
|
||||
$table->addColumn('content', Types::TEXT, [
|
||||
'notnull' => false,
|
||||
]);
|
||||
$table->addColumn('color', Types::STRING, [
|
||||
'notnull' => false,
|
||||
'length' => 16,
|
||||
]);
|
||||
$table->addColumn('created_by', Types::STRING, [
|
||||
'notnull' => true,
|
||||
'length' => 64,
|
||||
]);
|
||||
$table->addColumn('sort_order', Types::INTEGER, [
|
||||
'notnull' => true,
|
||||
'default' => 0,
|
||||
]);
|
||||
$table->addColumn('created_at', Types::BIGINT, [
|
||||
'notnull' => true,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->addColumn('updated_at', Types::BIGINT, [
|
||||
'notnull' => true,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->setPrimaryKey(['id']);
|
||||
$table->addIndex(['house_id'], 'pantry_notes_house_idx');
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,18 @@ namespace OCA\Pantry;
|
||||
* updatedAt: int,
|
||||
* }
|
||||
*
|
||||
* @psalm-type PantryNote = array{
|
||||
* id: int,
|
||||
* houseId: int,
|
||||
* title: string,
|
||||
* content: string|null,
|
||||
* color: string|null,
|
||||
* createdBy: string,
|
||||
* sortOrder: int,
|
||||
* createdAt: int,
|
||||
* updatedAt: int,
|
||||
* }
|
||||
*
|
||||
* @psalm-type PantryPhoto = array{
|
||||
* id: int,
|
||||
* houseId: int,
|
||||
|
||||
@@ -12,6 +12,7 @@ use OCA\Pantry\Db\House;
|
||||
use OCA\Pantry\Db\HouseMapper;
|
||||
use OCA\Pantry\Db\HouseMember;
|
||||
use OCA\Pantry\Db\HouseMemberMapper;
|
||||
use OCA\Pantry\Db\NoteMapper;
|
||||
use OCA\Pantry\Db\PhotoFolderMapper;
|
||||
use OCA\Pantry\Db\PhotoMapper;
|
||||
use OCA\Pantry\Db\ShoppingListItemMapper;
|
||||
@@ -31,6 +32,7 @@ class HouseService {
|
||||
private CategoryMapper $categoryMapper,
|
||||
private PhotoMapper $photoMapper,
|
||||
private PhotoFolderMapper $photoFolderMapper,
|
||||
private NoteMapper $noteMapper,
|
||||
private IDBConnection $db,
|
||||
private IUserManager $userManager,
|
||||
) {
|
||||
@@ -116,6 +118,7 @@ class HouseService {
|
||||
$this->categoryMapper->deleteByHouse($houseId);
|
||||
$this->photoMapper->deleteByHouse($houseId);
|
||||
$this->photoFolderMapper->deleteByHouse($houseId);
|
||||
$this->noteMapper->deleteByHouse($houseId);
|
||||
$this->memberMapper->deleteByHouse($houseId);
|
||||
$this->houseMapper->delete($house);
|
||||
$this->db->commit();
|
||||
|
||||
125
lib/Service/NotesWallService.php
Normal file
125
lib/Service/NotesWallService.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Service;
|
||||
|
||||
use OCA\Pantry\Db\Note;
|
||||
use OCA\Pantry\Db\NoteMapper;
|
||||
use OCA\Pantry\Exception\NotFoundException;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
|
||||
class NotesWallService {
|
||||
public function __construct(
|
||||
private NoteMapper $noteMapper,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Note[]
|
||||
*/
|
||||
public function listNotes(int $houseId): array {
|
||||
return $this->noteMapper->findByHouse($houseId);
|
||||
}
|
||||
|
||||
public function getNote(int $noteId): Note {
|
||||
try {
|
||||
return $this->noteMapper->findById($noteId);
|
||||
} catch (DoesNotExistException) {
|
||||
throw new NotFoundException('Note not found');
|
||||
}
|
||||
}
|
||||
|
||||
public function createNote(int $houseId, string $uid, string $title, ?string $content, ?string $color): Note {
|
||||
$title = trim($title);
|
||||
if ($title === '') {
|
||||
throw new \InvalidArgumentException('Note title cannot be empty');
|
||||
}
|
||||
if ($color !== null) {
|
||||
$this->validateColor($color);
|
||||
}
|
||||
$now = time();
|
||||
$note = new Note();
|
||||
$note->setHouseId($houseId);
|
||||
$note->setTitle($title);
|
||||
$note->setContent($content !== null && trim($content) !== '' ? $content : null);
|
||||
$note->setColor($color);
|
||||
$note->setCreatedBy($uid);
|
||||
$note->setSortOrder(0);
|
||||
$note->setCreatedAt($now);
|
||||
$note->setUpdatedAt($now);
|
||||
/** @var Note $saved */
|
||||
$saved = $this->noteMapper->insert($note);
|
||||
return $saved;
|
||||
}
|
||||
|
||||
public function updateNote(int $noteId, array $patch): Note {
|
||||
$note = $this->getNote($noteId);
|
||||
if (isset($patch['title'])) {
|
||||
$title = trim((string)$patch['title']);
|
||||
if ($title === '') {
|
||||
throw new \InvalidArgumentException('Note title cannot be empty');
|
||||
}
|
||||
$note->setTitle($title);
|
||||
}
|
||||
if (array_key_exists('content', $patch)) {
|
||||
$c = $patch['content'];
|
||||
$note->setContent(is_string($c) && trim($c) !== '' ? $c : null);
|
||||
}
|
||||
if (array_key_exists('color', $patch)) {
|
||||
$color = $patch['color'];
|
||||
if ($color !== null && $color !== '') {
|
||||
$this->validateColor((string)$color);
|
||||
$note->setColor((string)$color);
|
||||
} else {
|
||||
$note->setColor(null);
|
||||
}
|
||||
}
|
||||
if (isset($patch['sortOrder'])) {
|
||||
$note->setSortOrder((int)$patch['sortOrder']);
|
||||
}
|
||||
$note->setUpdatedAt(time());
|
||||
$this->noteMapper->update($note);
|
||||
return $note;
|
||||
}
|
||||
|
||||
public function deleteNote(int $noteId): void {
|
||||
$note = $this->getNote($noteId);
|
||||
$this->noteMapper->delete($note);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch reorder notes.
|
||||
*
|
||||
* @param list<array{id: int, sortOrder: int}> $items
|
||||
*/
|
||||
public function reorderNotes(int $houseId, array $items): void {
|
||||
foreach ($items as $entry) {
|
||||
$id = (int)($entry['id'] ?? 0);
|
||||
$sortOrder = (int)($entry['sortOrder'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$note = $this->noteMapper->findById($id);
|
||||
} catch (DoesNotExistException) {
|
||||
continue;
|
||||
}
|
||||
if ($note->getHouseId() !== $houseId) {
|
||||
continue;
|
||||
}
|
||||
$note->setSortOrder($sortOrder);
|
||||
$note->setUpdatedAt(time());
|
||||
$this->noteMapper->update($note);
|
||||
}
|
||||
}
|
||||
|
||||
private function validateColor(string $color): void {
|
||||
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
|
||||
throw new \InvalidArgumentException('Color must be a hex color code (#RRGGBB)');
|
||||
}
|
||||
}
|
||||
}
|
||||
689
openapi.json
689
openapi.json
@@ -285,6 +285,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Note": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"houseId",
|
||||
"title",
|
||||
"content",
|
||||
"color",
|
||||
"createdBy",
|
||||
"sortOrder",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"houseId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"createdBy": {
|
||||
"type": "string"
|
||||
},
|
||||
"sortOrder": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
},
|
||||
"OCSMeta": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -2050,6 +2100,645 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/pantry/api/houses/{houseId}/notes": {
|
||||
"get": {
|
||||
"operationId": "notes_wall-index-notes",
|
||||
"summary": "List all notes in a house",
|
||||
"tags": [
|
||||
"notes_wall"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "houseId",
|
||||
"in": "path",
|
||||
"description": "House id.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"description": "Maximum number of notes to return.",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"default": 100,
|
||||
"minimum": 1,
|
||||
"maximum": 500
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"description": "Number of notes to skip.",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"default": 0,
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Notes returned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Note"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"operationId": "notes_wall-create-note",
|
||||
"summary": "Create a note",
|
||||
"tags": [
|
||||
"notes_wall"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Note title."
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"default": null,
|
||||
"description": "Markdown content."
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"default": null,
|
||||
"description": "Hex color (#RRGGBB)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "houseId",
|
||||
"in": "path",
|
||||
"description": "House id.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Note created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Note"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/pantry/api/houses/{houseId}/notes/{noteId}": {
|
||||
"patch": {
|
||||
"operationId": "notes_wall-update-note",
|
||||
"summary": "Update a note",
|
||||
"tags": [
|
||||
"notes_wall"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": false,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"default": null,
|
||||
"description": "New title."
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"default": null,
|
||||
"description": "New content (empty string clears)."
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"default": null,
|
||||
"description": "New color (empty string clears)."
|
||||
},
|
||||
"sortOrder": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"nullable": true,
|
||||
"default": null,
|
||||
"description": "New sort order."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "houseId",
|
||||
"in": "path",
|
||||
"description": "House id.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "noteId",
|
||||
"in": "path",
|
||||
"description": "Note id.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Note updated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Note"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "notes_wall-delete-note",
|
||||
"summary": "Delete a note",
|
||||
"tags": [
|
||||
"notes_wall"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "houseId",
|
||||
"in": "path",
|
||||
"description": "House id.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "noteId",
|
||||
"in": "path",
|
||||
"description": "Note id.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Note deleted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/pantry/api/houses/{houseId}/notes/reorder": {
|
||||
"post": {
|
||||
"operationId": "notes_wall-reorder-notes",
|
||||
"summary": "Batch reorder notes",
|
||||
"tags": [
|
||||
"notes_wall"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": false,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"default": [],
|
||||
"description": "Reorder entries.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"sortOrder"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"sortOrder": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "houseId",
|
||||
"in": "path",
|
||||
"description": "House id.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Notes reordered",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/pantry/api/houses/{houseId}/photos/folders": {
|
||||
"get": {
|
||||
"operationId": "photo_wall-index-folders",
|
||||
|
||||
41
src/api/notes.ts
Normal file
41
src/api/notes.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ocs } from '@/axios'
|
||||
import type { Note } from './types'
|
||||
|
||||
export async function listNotes(houseId: number): Promise<Note[]> {
|
||||
const resp = await ocs.get<Note[]>(`/houses/${houseId}/notes`)
|
||||
return resp.data ?? []
|
||||
}
|
||||
|
||||
export async function createNote(
|
||||
houseId: number,
|
||||
title: string,
|
||||
content?: string | null,
|
||||
color?: string | null,
|
||||
): Promise<Note> {
|
||||
const resp = await ocs.post<Note>(`/houses/${houseId}/notes`, {
|
||||
title,
|
||||
content: content ?? null,
|
||||
color: color ?? null,
|
||||
})
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function updateNote(
|
||||
houseId: number,
|
||||
noteId: number,
|
||||
patch: { title?: string; content?: string; color?: string; sortOrder?: number },
|
||||
): Promise<Note> {
|
||||
const resp = await ocs.patch<Note>(`/houses/${houseId}/notes/${noteId}`, patch)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function deleteNote(houseId: number, noteId: number): Promise<void> {
|
||||
await ocs.delete(`/houses/${houseId}/notes/${noteId}`)
|
||||
}
|
||||
|
||||
export async function reorderNotes(
|
||||
houseId: number,
|
||||
items: { id: number; sortOrder: number }[],
|
||||
): Promise<void> {
|
||||
await ocs.post(`/houses/${houseId}/notes/reorder`, { items })
|
||||
}
|
||||
@@ -58,6 +58,18 @@ export interface ShoppingListItem {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: number
|
||||
houseId: number
|
||||
title: string
|
||||
content: string | null
|
||||
color: string | null
|
||||
createdBy: string
|
||||
sortOrder: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface PhotoFolder {
|
||||
id: number
|
||||
houseId: number
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
v-if="showCreate"
|
||||
:name="strings.createTitle"
|
||||
:open="showCreate"
|
||||
close-on-click-outside
|
||||
@update:open="showCreate = $event"
|
||||
>
|
||||
<form class="pantry-create-cat" @submit.prevent="submitCreate">
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
v-if="showAdd"
|
||||
:name="strings.addDialogTitle"
|
||||
:open="showAdd"
|
||||
close-on-click-outside
|
||||
@update:open="showAdd = $event"
|
||||
>
|
||||
<form class="pantry-form" @submit.prevent="submitAdd">
|
||||
@@ -124,6 +125,7 @@
|
||||
v-if="confirmingDelete"
|
||||
:name="strings.deleteDialogTitle"
|
||||
:open="confirmingDelete"
|
||||
close-on-click-outside
|
||||
@update:open="confirmingDelete = $event"
|
||||
>
|
||||
<p>{{ strings.deleteConfirmBody }}</p>
|
||||
|
||||
158
src/components/NotesWall/NoteCard.test.ts
Normal file
158
src/components/NotesWall/NoteCard.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createIconMock, nextcloudL10nMock } from '@/test-utils'
|
||||
import type { Note } from '@/api/types'
|
||||
|
||||
vi.mock('@nextcloud/l10n', () => nextcloudL10nMock)
|
||||
vi.mock('@icons/Delete.vue', () => createIconMock('DeleteIcon'))
|
||||
|
||||
vi.mock('@nextcloud/vue/components/NcActions', () => ({
|
||||
default: {
|
||||
name: 'NcActions',
|
||||
template: '<div class="nc-actions"><slot /></div>',
|
||||
props: ['ariaLabel'],
|
||||
},
|
||||
}))
|
||||
vi.mock('@nextcloud/vue/components/NcActionButton', () => ({
|
||||
default: {
|
||||
name: 'NcActionButton',
|
||||
template: '<button class="nc-action-button"><slot name="icon" /><slot /></button>',
|
||||
},
|
||||
}))
|
||||
vi.mock('@nextcloud/vue/components/NcRichText', () => ({
|
||||
default: {
|
||||
name: 'NcRichText',
|
||||
template: '<div class="nc-rich-text">{{ text }}</div>',
|
||||
props: ['text', 'useMarkdown', 'useExtendedMarkdown'],
|
||||
},
|
||||
}))
|
||||
|
||||
import NoteCard from './NoteCard.vue'
|
||||
|
||||
function makeNote(overrides: Partial<Note> = {}): Note {
|
||||
return {
|
||||
id: 1,
|
||||
houseId: 1,
|
||||
title: 'Groceries',
|
||||
content: null,
|
||||
color: null,
|
||||
createdBy: 'admin',
|
||||
sortOrder: 0,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('NoteCard', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders the title', () => {
|
||||
const wrapper = mount(NoteCard, { props: { note: makeNote({ title: 'My Note' }) } })
|
||||
expect(wrapper.find('.note-card__title').text()).toBe('My Note')
|
||||
})
|
||||
|
||||
it('renders content with NcRichText when present', () => {
|
||||
const wrapper = mount(NoteCard, {
|
||||
props: { note: makeNote({ content: '**bold**' }) },
|
||||
})
|
||||
expect(wrapper.find('.note-card__content').exists()).toBe(true)
|
||||
expect(wrapper.find('.nc-rich-text').text()).toBe('**bold**')
|
||||
})
|
||||
|
||||
it('does not render content section when null', () => {
|
||||
const wrapper = mount(NoteCard, { props: { note: makeNote() } })
|
||||
expect(wrapper.find('.note-card__content').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('applies color as CSS custom properties', () => {
|
||||
const wrapper = mount(NoteCard, {
|
||||
props: { note: makeNote({ color: '#ff0000' }) },
|
||||
})
|
||||
const style = wrapper.find('.note-card').attributes('style')
|
||||
expect(style).toContain('--note-bg: #ff0000')
|
||||
expect(style).toContain('--note-fg:')
|
||||
})
|
||||
|
||||
it('uses white text on dark background', () => {
|
||||
const wrapper = mount(NoteCard, {
|
||||
props: { note: makeNote({ color: '#1a1a1a' }) },
|
||||
})
|
||||
const style = wrapper.find('.note-card').attributes('style')
|
||||
expect(style).toContain('--note-fg: #ffffff')
|
||||
})
|
||||
|
||||
it('uses black text on light background', () => {
|
||||
const wrapper = mount(NoteCard, {
|
||||
props: { note: makeNote({ color: '#ffeb3b' }) },
|
||||
})
|
||||
const style = wrapper.find('.note-card').attributes('style')
|
||||
expect(style).toContain('--note-fg: #000000')
|
||||
})
|
||||
|
||||
it('has no inline style when no color', () => {
|
||||
const wrapper = mount(NoteCard, { props: { note: makeNote() } })
|
||||
expect(wrapper.find('.note-card').attributes('style')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('is draggable', () => {
|
||||
const wrapper = mount(NoteCard, { props: { note: makeNote() } })
|
||||
expect(wrapper.find('.note-card').attributes('draggable')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('actions', () => {
|
||||
it('shows delete action', () => {
|
||||
const wrapper = mount(NoteCard, { props: { note: makeNote() } })
|
||||
const texts = wrapper.findAll('.nc-action-button').map((b) => b.text())
|
||||
expect(texts).toContain('Delete')
|
||||
})
|
||||
|
||||
it('does not emit edit when actions wrapper is clicked', async () => {
|
||||
const wrapper = mount(NoteCard, { props: { note: makeNote() } })
|
||||
await wrapper.find('.note-card__actions').trigger('click')
|
||||
expect(wrapper.emitted('edit')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('events', () => {
|
||||
it('emits edit on card click', async () => {
|
||||
const note = makeNote()
|
||||
const wrapper = mount(NoteCard, { props: { note } })
|
||||
await wrapper.find('.note-card').trigger('click')
|
||||
expect(wrapper.emitted('edit')).toBeTruthy()
|
||||
expect(wrapper.emitted('edit')![0]).toEqual([note])
|
||||
})
|
||||
|
||||
it('emits delete when Delete action is clicked', async () => {
|
||||
const note = makeNote()
|
||||
const wrapper = mount(NoteCard, { props: { note } })
|
||||
const delBtn = wrapper.findAll('.nc-action-button').find((b) => b.text() === 'Delete')!
|
||||
await delBtn.trigger('click')
|
||||
expect(wrapper.emitted('delete')).toBeTruthy()
|
||||
expect(wrapper.emitted('delete')![0]).toEqual([note])
|
||||
})
|
||||
|
||||
it('emits drag-start on dragstart', async () => {
|
||||
const wrapper = mount(NoteCard, { props: { note: makeNote({ id: 7 }) } })
|
||||
await wrapper.find('.note-card').trigger('dragstart', {
|
||||
dataTransfer: { effectAllowed: '', setData: vi.fn() },
|
||||
})
|
||||
expect(wrapper.emitted('drag-start')).toBeTruthy()
|
||||
expect(wrapper.emitted('drag-start')![0]).toEqual([7])
|
||||
})
|
||||
|
||||
it('applies dragging class on dragstart and removes on dragend', async () => {
|
||||
const wrapper = mount(NoteCard, { props: { note: makeNote() } })
|
||||
const card = wrapper.find('.note-card')
|
||||
|
||||
await card.trigger('dragstart', {
|
||||
dataTransfer: { effectAllowed: '', setData: vi.fn() },
|
||||
})
|
||||
expect(card.classes()).toContain('note-card--dragging')
|
||||
|
||||
await card.trigger('dragend')
|
||||
expect(card.classes()).not.toContain('note-card--dragging')
|
||||
})
|
||||
})
|
||||
})
|
||||
153
src/components/NotesWall/NoteCard.vue
Normal file
153
src/components/NotesWall/NoteCard.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div
|
||||
class="note-card"
|
||||
:class="{ 'note-card--dragging': isDragging }"
|
||||
:style="cardStyle"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@dragover.prevent="onDragOver"
|
||||
@click="$emit('edit', note)"
|
||||
>
|
||||
<div class="note-card__actions" @click.stop>
|
||||
<NcActions :aria-label="strings.actions">
|
||||
<NcActionButton @click.stop="$emit('delete', note)">
|
||||
<template #icon><DeleteIcon :size="20" /></template>
|
||||
{{ strings.delete }}
|
||||
</NcActionButton>
|
||||
</NcActions>
|
||||
</div>
|
||||
<h3 class="note-card__title">{{ note.title }}</h3>
|
||||
<div v-if="note.content" class="note-card__content">
|
||||
<NcRichText :text="note.content" :use-markdown="true" :use-extended-markdown="true" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcActions from '@nextcloud/vue/components/NcActions'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
import NcRichText from '@nextcloud/vue/components/NcRichText'
|
||||
import DeleteIcon from '@icons/Delete.vue'
|
||||
import { contrastColor } from './noteColors'
|
||||
import type { Note } from '@/api/types'
|
||||
|
||||
const props = defineProps<{ note: Note }>()
|
||||
const emit = defineEmits<{
|
||||
edit: [note: Note]
|
||||
delete: [note: Note]
|
||||
'drag-start': [noteId: number]
|
||||
'reorder-over': [noteId: number, event: MouseEvent]
|
||||
}>()
|
||||
|
||||
const isDragging = ref(false)
|
||||
|
||||
const cardStyle = computed(() => {
|
||||
if (!props.note.color) return {}
|
||||
return {
|
||||
'--note-bg': props.note.color,
|
||||
'--note-fg': contrastColor(props.note.color),
|
||||
}
|
||||
})
|
||||
|
||||
function onDragStart(e: DragEvent) {
|
||||
if (!e.dataTransfer) return
|
||||
isDragging.value = true
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('application/x-pantry-note', String(props.note.id))
|
||||
emit('drag-start', props.note.id)
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent) {
|
||||
if (!e.dataTransfer?.types.includes('application/x-pantry-note')) return
|
||||
emit('reorder-over', props.note.id, e)
|
||||
}
|
||||
|
||||
const strings = {
|
||||
actions: t('pantry', 'Note actions'),
|
||||
delete: t('pantry', 'Delete'),
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.note-card {
|
||||
position: relative;
|
||||
border-radius: var(--border-radius-large, 12px);
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
padding: 1rem;
|
||||
background: var(--note-bg, var(--color-background-hover));
|
||||
color: var(--note-fg, inherit);
|
||||
border: 1px solid var(--color-border);
|
||||
transition:
|
||||
box-shadow 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
min-height: 80px;
|
||||
max-height: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&--dragging {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.95);
|
||||
pointer-events: none;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
inset-inline-end: 0.25rem;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
&:hover &__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-inline-end: 2rem;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
// Fade out overflowing text
|
||||
mask-image: linear-gradient(to bottom, black 70%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 70%, transparent 100%);
|
||||
|
||||
// Force all rendered markdown elements to inherit the note's text color
|
||||
:deep(*) {
|
||||
color: inherit !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
329
src/components/NotesWall/NoteDialog.test.ts
Normal file
329
src/components/NotesWall/NoteDialog.test.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
import { createIconMock, nextcloudL10nMock } from '@/test-utils'
|
||||
import type { Note } from '@/api/types'
|
||||
|
||||
vi.mock('@nextcloud/l10n', () => nextcloudL10nMock)
|
||||
vi.mock('@icons/Pencil.vue', () => createIconMock('PencilIcon'))
|
||||
vi.mock('@icons/Eye.vue', () => createIconMock('EyeIcon'))
|
||||
|
||||
vi.mock('@nextcloud/vue/components/NcDialog', () => ({
|
||||
default: {
|
||||
name: 'NcDialog',
|
||||
template: '<div class="nc-dialog"><slot /><slot name="actions" /></div>',
|
||||
props: ['name', 'open', 'size'],
|
||||
},
|
||||
}))
|
||||
vi.mock('@nextcloud/vue/components/NcButton', () => ({
|
||||
default: {
|
||||
name: 'NcButton',
|
||||
template:
|
||||
'<button class="nc-button" :disabled="disabled"><slot name="icon" /><slot /></button>',
|
||||
props: ['variant', 'form', 'type', 'disabled', 'ariaLabel'],
|
||||
},
|
||||
}))
|
||||
// NcTextField no longer used in NoteDialog (title uses a plain <input>)
|
||||
vi.mock('@nextcloud/vue/components/NcTextArea', () => ({
|
||||
default: {
|
||||
name: 'NcTextArea',
|
||||
template:
|
||||
'<textarea class="nc-text-area" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
props: ['modelValue', 'label', 'placeholder', 'resize', 'rows'],
|
||||
emits: ['update:modelValue'],
|
||||
},
|
||||
}))
|
||||
vi.mock('@nextcloud/vue/components/NcRichText', () => ({
|
||||
default: {
|
||||
name: 'NcRichText',
|
||||
template: '<div class="nc-rich-text">{{ text }}</div>',
|
||||
props: ['text', 'useMarkdown', 'useExtendedMarkdown'],
|
||||
},
|
||||
}))
|
||||
|
||||
import NoteDialog from './NoteDialog.vue'
|
||||
|
||||
function makeNote(overrides: Partial<Note> = {}): Note {
|
||||
return {
|
||||
id: 1,
|
||||
houseId: 1,
|
||||
title: 'Existing Note',
|
||||
content: 'Some content',
|
||||
color: '#f44336',
|
||||
createdBy: 'admin',
|
||||
sortOrder: 0,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('NoteDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('create mode (no note)', () => {
|
||||
it('opens in edit mode with editable fields', () => {
|
||||
const wrapper = mount(NoteDialog, { props: { open: true } })
|
||||
expect(wrapper.find('.note-dialog__title-input').exists()).toBe(true)
|
||||
expect(wrapper.find('.nc-text-area').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('starts with empty fields', () => {
|
||||
const wrapper = mount(NoteDialog, { props: { open: true } })
|
||||
expect((wrapper.find('.note-dialog__title-input').element as HTMLInputElement).value).toBe('')
|
||||
expect((wrapper.find('.nc-text-area').element as HTMLTextAreaElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('saves on close when title is set', async () => {
|
||||
const wrapper = mount(NoteDialog, { props: { open: true } })
|
||||
await wrapper.find('.note-dialog__title-input').setValue('New Note')
|
||||
await wrapper.find('.nc-text-area').setValue('Content')
|
||||
// Simulate dialog close (click outside / X button)
|
||||
wrapper.findComponent({ name: 'NcDialog' }).vm.$emit('update:open', false)
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('save')).toBeTruthy()
|
||||
expect(wrapper.emitted('save')![0][0].title).toBe('New Note')
|
||||
})
|
||||
|
||||
it('does not save on close when title is empty', async () => {
|
||||
const wrapper = mount(NoteDialog, { props: { open: true } })
|
||||
wrapper.findComponent({ name: 'NcDialog' }).vm.$emit('update:open', false)
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('save')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('view mode (existing note)', () => {
|
||||
it('opens in view mode showing rendered content', () => {
|
||||
const wrapper = mount(NoteDialog, {
|
||||
props: { open: true, note: makeNote() },
|
||||
})
|
||||
expect(wrapper.find('.nc-rich-text').exists()).toBe(true)
|
||||
expect(wrapper.find('.nc-text-area').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows title as heading text', () => {
|
||||
const wrapper = mount(NoteDialog, {
|
||||
props: { open: true, note: makeNote({ title: 'My Note' }) },
|
||||
})
|
||||
expect(wrapper.find('.note-dialog__title-text').text()).toBe('My Note')
|
||||
})
|
||||
|
||||
it('shows empty message when no content', () => {
|
||||
const wrapper = mount(NoteDialog, {
|
||||
props: { open: true, note: makeNote({ content: null }) },
|
||||
})
|
||||
expect(wrapper.find('.note-dialog__empty').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('switches to edit mode on content click', async () => {
|
||||
const wrapper = mount(NoteDialog, {
|
||||
props: { open: true, note: makeNote() },
|
||||
})
|
||||
await wrapper.find('.note-dialog__content').trigger('click')
|
||||
expect(wrapper.find('.nc-text-area').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('switches to edit mode on title click', async () => {
|
||||
const wrapper = mount(NoteDialog, {
|
||||
props: { open: true, note: makeNote() },
|
||||
})
|
||||
await wrapper.find('.note-dialog__title-text').trigger('click')
|
||||
expect(wrapper.find('.note-dialog__title-input').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not show title input in view mode', () => {
|
||||
const wrapper = mount(NoteDialog, {
|
||||
props: { open: true, note: makeNote() },
|
||||
})
|
||||
expect(wrapper.find('.note-dialog__title-input').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('passes empty name to NcDialog', () => {
|
||||
const wrapper = mount(NoteDialog, {
|
||||
props: { open: true, note: makeNote() },
|
||||
})
|
||||
const dialog = wrapper.findComponent({ name: 'NcDialog' })
|
||||
expect(dialog.props('name')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit/view toggle button', () => {
|
||||
it('shows edit icon in view mode', () => {
|
||||
const wrapper = mount(NoteDialog, {
|
||||
props: { open: true, note: makeNote() },
|
||||
})
|
||||
expect(wrapper.find('.mock-pencil-icon').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows eye icon in edit mode', () => {
|
||||
const wrapper = mount(NoteDialog, { props: { open: true } })
|
||||
expect(wrapper.find('.mock-eye-icon').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('toggles between edit and view on click', async () => {
|
||||
const wrapper = mount(NoteDialog, {
|
||||
props: { open: true, note: makeNote() },
|
||||
})
|
||||
// Start in view mode — no text field
|
||||
expect(wrapper.find('.note-dialog__title-input').exists()).toBe(false)
|
||||
// Click toggle to switch to edit
|
||||
const toggleBtn = wrapper
|
||||
.findAll('.nc-button')
|
||||
.find((b) => b.find('.mock-pencil-icon').exists())!
|
||||
await toggleBtn.trigger('click')
|
||||
expect(wrapper.find('.note-dialog__title-input').exists()).toBe(true)
|
||||
// Click toggle to switch back to view
|
||||
const viewBtn = wrapper.findAll('.nc-button').find((b) => b.find('.mock-eye-icon').exists())!
|
||||
await viewBtn.trigger('click')
|
||||
expect(wrapper.find('.note-dialog__title-input').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('auto-save', () => {
|
||||
it('debounces saves on text changes', async () => {
|
||||
const wrapper = mount(NoteDialog, {
|
||||
props: { open: true, note: makeNote() },
|
||||
})
|
||||
// Switch to edit mode via toggle
|
||||
const toggleBtn = wrapper
|
||||
.findAll('.nc-button')
|
||||
.find((b) => b.find('.mock-pencil-icon').exists())!
|
||||
await toggleBtn.trigger('click')
|
||||
await wrapper.find('.note-dialog__title-input').setValue('Updated Title')
|
||||
|
||||
expect(wrapper.emitted('save')).toBeFalsy()
|
||||
vi.advanceTimersByTime(1000)
|
||||
expect(wrapper.emitted('save')).toBeTruthy()
|
||||
expect(wrapper.emitted('save')![0][0].title).toBe('Updated Title')
|
||||
})
|
||||
|
||||
it('debounces saves on content changes', async () => {
|
||||
const wrapper = mount(NoteDialog, {
|
||||
props: { open: true, note: makeNote() },
|
||||
})
|
||||
// Switch to edit mode
|
||||
const toggleBtn = wrapper
|
||||
.findAll('.nc-button')
|
||||
.find((b) => b.find('.mock-pencil-icon').exists())!
|
||||
await toggleBtn.trigger('click')
|
||||
await wrapper.find('.nc-text-area').setValue('New content')
|
||||
|
||||
expect(wrapper.emitted('save')).toBeFalsy()
|
||||
vi.advanceTimersByTime(1000)
|
||||
expect(wrapper.emitted('save')).toBeTruthy()
|
||||
expect(wrapper.emitted('save')![0][0].content).toBe('New content')
|
||||
})
|
||||
|
||||
it('flushes save when toggling back to view mode', async () => {
|
||||
const wrapper = mount(NoteDialog, {
|
||||
props: { open: true, note: makeNote() },
|
||||
})
|
||||
// Switch to edit
|
||||
const editBtn = wrapper
|
||||
.findAll('.nc-button')
|
||||
.find((b) => b.find('.mock-pencil-icon').exists())!
|
||||
await editBtn.trigger('click')
|
||||
await wrapper.find('.note-dialog__title-input').setValue('Modified')
|
||||
// Toggle back to view (should flush)
|
||||
const viewBtn = wrapper.findAll('.nc-button').find((b) => b.find('.mock-eye-icon').exists())!
|
||||
await viewBtn.trigger('click')
|
||||
expect(wrapper.emitted('save')).toBeTruthy()
|
||||
expect(wrapper.emitted('save')![0][0].title).toBe('Modified')
|
||||
})
|
||||
|
||||
it('does not auto-save for new notes', async () => {
|
||||
const wrapper = mount(NoteDialog, { props: { open: true } })
|
||||
await wrapper.find('.note-dialog__title-input').setValue('New Title')
|
||||
vi.advanceTimersByTime(1000)
|
||||
// Should not auto-save — only saves on close
|
||||
expect(wrapper.emitted('save')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('flushes save on close', async () => {
|
||||
const wrapper = mount(NoteDialog, {
|
||||
props: { open: true, note: makeNote() },
|
||||
})
|
||||
// Switch to edit mode via toggle
|
||||
const toggleBtn = wrapper
|
||||
.findAll('.nc-button')
|
||||
.find((b) => b.find('.mock-pencil-icon').exists())!
|
||||
await toggleBtn.trigger('click')
|
||||
await wrapper.find('.note-dialog__title-input').setValue('Changed')
|
||||
// Simulate dialog close
|
||||
wrapper.findComponent({ name: 'NcDialog' }).vm.$emit('update:open', false)
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('save')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('color swatches', () => {
|
||||
it('renders 16 swatches in both modes', () => {
|
||||
const wrapper = mount(NoteDialog, {
|
||||
props: { open: true, note: makeNote() },
|
||||
})
|
||||
expect(wrapper.findAll('.note-dialog__swatch')).toHaveLength(16)
|
||||
})
|
||||
|
||||
it('toggles color on swatch click', async () => {
|
||||
const wrapper = mount(NoteDialog, {
|
||||
props: { open: true, note: makeNote({ color: null }) },
|
||||
})
|
||||
const swatch = wrapper.find('.note-dialog__swatch')
|
||||
await swatch.trigger('click')
|
||||
expect(swatch.classes()).toContain('note-dialog__swatch--active')
|
||||
await swatch.trigger('click')
|
||||
expect(swatch.classes()).not.toContain('note-dialog__swatch--active')
|
||||
})
|
||||
|
||||
it('saves immediately on color change for existing notes', async () => {
|
||||
const wrapper = mount(NoteDialog, {
|
||||
props: { open: true, note: makeNote() },
|
||||
})
|
||||
await wrapper.findAll('.note-dialog__swatch').at(5)!.trigger('click')
|
||||
expect(wrapper.emitted('save')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('pre-selects existing note color', () => {
|
||||
const wrapper = mount(NoteDialog, {
|
||||
props: { open: true, note: makeNote({ color: '#f44336' }) },
|
||||
})
|
||||
const activeSwatch = wrapper.find('.note-dialog__swatch--active')
|
||||
expect(activeSwatch.exists()).toBe(true)
|
||||
expect(activeSwatch.attributes('style')).toContain('#f44336')
|
||||
})
|
||||
|
||||
it('uses contrast color for active swatch border', () => {
|
||||
// Dark color → white contrast → white border
|
||||
const wrapper = mount(NoteDialog, {
|
||||
props: { open: true, note: makeNote({ color: '#3f51b5' }) },
|
||||
})
|
||||
const activeSwatch = wrapper.find('.note-dialog__swatch--active')
|
||||
expect(activeSwatch.attributes('style')).toContain('border-color: #ffffff')
|
||||
})
|
||||
|
||||
it('does not save on color change for new notes', async () => {
|
||||
const wrapper = mount(NoteDialog, { props: { open: true } })
|
||||
await wrapper.find('.note-dialog__swatch').trigger('click')
|
||||
vi.advanceTimersByTime(1000)
|
||||
// New notes don't auto-save
|
||||
expect(wrapper.emitted('save')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('shows swatches in view mode too', () => {
|
||||
const wrapper = mount(NoteDialog, {
|
||||
props: { open: true, note: makeNote() },
|
||||
})
|
||||
// Should be in view mode
|
||||
expect(wrapper.find('.nc-text-area').exists()).toBe(false)
|
||||
// Swatches still visible
|
||||
expect(wrapper.findAll('.note-dialog__swatch')).toHaveLength(16)
|
||||
})
|
||||
})
|
||||
})
|
||||
494
src/components/NotesWall/NoteDialog.vue
Normal file
494
src/components/NotesWall/NoteDialog.vue
Normal file
@@ -0,0 +1,494 @@
|
||||
<template>
|
||||
<NcDialog
|
||||
ref="dialogRef"
|
||||
name=""
|
||||
:open="open"
|
||||
close-on-click-outside
|
||||
size="normal"
|
||||
@update:open="onClose"
|
||||
>
|
||||
<div class="note-dialog__body">
|
||||
<!-- Title -->
|
||||
<input
|
||||
v-if="editing"
|
||||
ref="titleInputRef"
|
||||
v-model="titleValue"
|
||||
:placeholder="strings.titlePlaceholder"
|
||||
class="note-dialog__title-input"
|
||||
/>
|
||||
<h2 v-else class="note-dialog__title-text" @click="startEditing('title')">
|
||||
{{ titleValue || strings.untitled }}
|
||||
</h2>
|
||||
|
||||
<!-- Content -->
|
||||
<NcTextArea
|
||||
v-if="editing"
|
||||
ref="contentInputRef"
|
||||
v-model="contentValue"
|
||||
:placeholder="strings.contentPlaceholder"
|
||||
resize="none"
|
||||
rows="3"
|
||||
class="note-dialog__content-input"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
ref="renderedContentRef"
|
||||
class="note-dialog__content"
|
||||
@click="startEditing('content')"
|
||||
>
|
||||
<div v-if="contentValue" class="note-dialog__rendered">
|
||||
<NcRichText :text="contentValue" :use-markdown="true" :use-extended-markdown="true" />
|
||||
</div>
|
||||
<p v-else class="note-dialog__empty">{{ strings.noContent }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Color swatches (always visible) -->
|
||||
<div class="note-dialog__color">
|
||||
<div class="note-dialog__swatches">
|
||||
<button
|
||||
v-for="c in colorOptions"
|
||||
:key="c"
|
||||
type="button"
|
||||
class="note-dialog__swatch"
|
||||
:class="{ 'note-dialog__swatch--active': colorValue === c }"
|
||||
:style="{
|
||||
background: c,
|
||||
borderColor: colorValue === c ? swatchBorderColor : 'transparent',
|
||||
}"
|
||||
:aria-label="c"
|
||||
@click="toggleColor(c)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<NcButton
|
||||
variant="tertiary"
|
||||
:aria-label="editing ? strings.view : strings.edit"
|
||||
@click="toggleEditing"
|
||||
>
|
||||
<template #icon>
|
||||
<EyeIcon v-if="editing" :size="20" />
|
||||
<PencilIcon v-else :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcDialog from '@nextcloud/vue/components/NcDialog'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcTextArea from '@nextcloud/vue/components/NcTextArea'
|
||||
import NcRichText from '@nextcloud/vue/components/NcRichText'
|
||||
import PencilIcon from '@icons/Pencil.vue'
|
||||
import EyeIcon from '@icons/Eye.vue'
|
||||
import { contrastColor, noteColorOptions } from './noteColors'
|
||||
import type { Note } from '@/api/types'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
note?: Note | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
save: [data: { title: string; content: string; color: string }]
|
||||
}>()
|
||||
|
||||
const titleValue = ref('')
|
||||
const contentValue = ref('')
|
||||
const colorValue = ref('')
|
||||
const editing = ref(false)
|
||||
const dialogRef = ref<InstanceType<typeof NcDialog> | null>(null)
|
||||
const titleInputRef = ref<HTMLInputElement | null>(null)
|
||||
const contentInputRef = ref<InstanceType<typeof NcTextArea> | null>(null)
|
||||
const renderedContentRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const MAX_TEXTAREA_HEIGHT = 400
|
||||
|
||||
const isExisting = computed(() => !!props.note)
|
||||
const swatchBorderColor = computed(() =>
|
||||
colorValue.value ? contrastColor(colorValue.value) : 'var(--color-main-text)',
|
||||
)
|
||||
const colorOptions = noteColorOptions
|
||||
|
||||
// ----- Dialog background color -----
|
||||
|
||||
function applyDialogColor() {
|
||||
nextTick(() => {
|
||||
// NcDialog teleports its content, so we need to find the dialog container
|
||||
// through the DOM. The dialog ref's $el may be a comment node (teleport anchor).
|
||||
const el = dialogRef.value?.$el as HTMLElement | undefined
|
||||
// Try via the component's $el first, then walk up, then search globally
|
||||
let container: HTMLElement | null = null
|
||||
if (el) {
|
||||
container = el.closest?.('.modal-container') as HTMLElement | null
|
||||
if (!container) {
|
||||
container = el.querySelector?.('.modal-container') as HTMLElement | null
|
||||
}
|
||||
}
|
||||
// Fallback: search all open dialog containers and use the last one (most recent)
|
||||
if (!container) {
|
||||
const all = document.querySelectorAll('.modal-container')
|
||||
container = all.length > 0 ? (all[all.length - 1] as HTMLElement) : null
|
||||
}
|
||||
if (!container) return
|
||||
const fg = colorValue.value ? contrastColor(colorValue.value) : ''
|
||||
if (colorValue.value) {
|
||||
container.style.background = colorValue.value
|
||||
container.style.color = fg
|
||||
} else {
|
||||
container.style.background = ''
|
||||
container.style.color = ''
|
||||
}
|
||||
// Hide the empty dialog name element
|
||||
const nameEl = container.querySelector<HTMLElement>('.dialog__name')
|
||||
if (nameEl) {
|
||||
nameEl.style.display = 'none'
|
||||
}
|
||||
// Style header/action buttons and set hover background
|
||||
container
|
||||
.querySelectorAll<HTMLElement>(
|
||||
'.dialog__actions button, .modal-header *, .dialog__close button, .modal-container__close, .modal-container__close *',
|
||||
)
|
||||
.forEach((el) => {
|
||||
el.style.color = fg
|
||||
})
|
||||
// Set a CSS variable for button hover backgrounds
|
||||
container.style.setProperty(
|
||||
'--note-btn-hover',
|
||||
fg ? `color-mix(in srgb, ${fg} 15%, transparent)` : '',
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ----- Lifecycle -----
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(v) => {
|
||||
if (v) {
|
||||
titleValue.value = props.note?.title ?? ''
|
||||
contentValue.value = props.note?.content ?? ''
|
||||
colorValue.value = props.note?.color ?? ''
|
||||
editing.value = !props.note
|
||||
nextTick(applyDialogColor)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(colorValue, applyDialogColor)
|
||||
|
||||
// ----- Auto-save with debounce -----
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function scheduleSave() {
|
||||
if (!isExisting.value) return
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(flushSave, 800)
|
||||
}
|
||||
|
||||
function flushSave() {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
const title = titleValue.value.trim()
|
||||
if (!title) return
|
||||
emit('save', {
|
||||
title,
|
||||
content: contentValue.value,
|
||||
color: colorValue.value,
|
||||
})
|
||||
}
|
||||
|
||||
watch(titleValue, scheduleSave)
|
||||
watch(contentValue, scheduleSave)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
// ----- Color toggle (saves immediately for existing) -----
|
||||
|
||||
function toggleColor(c: string) {
|
||||
colorValue.value = colorValue.value === c ? '' : c
|
||||
if (isExisting.value) {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
const title = titleValue.value.trim()
|
||||
if (!title) return
|
||||
emit('save', {
|
||||
title,
|
||||
content: contentValue.value,
|
||||
color: colorValue.value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Auto-resize textarea -----
|
||||
|
||||
function getTextarea(): HTMLTextAreaElement | null {
|
||||
const el = contentInputRef.value?.$el as HTMLElement | undefined
|
||||
return el?.querySelector('textarea') ?? null
|
||||
}
|
||||
|
||||
function autoResizeTextarea() {
|
||||
const ta = getTextarea()
|
||||
if (!ta) return
|
||||
ta.style.height = 'auto'
|
||||
ta.style.height = Math.min(ta.scrollHeight, MAX_TEXTAREA_HEIGHT) + 'px'
|
||||
ta.style.overflowY = ta.scrollHeight > MAX_TEXTAREA_HEIGHT ? 'auto' : 'hidden'
|
||||
}
|
||||
|
||||
watch(contentValue, () => {
|
||||
if (editing.value) {
|
||||
nextTick(autoResizeTextarea)
|
||||
}
|
||||
})
|
||||
|
||||
// ----- Edit mode -----
|
||||
|
||||
function toggleEditing() {
|
||||
if (editing.value) {
|
||||
editing.value = false
|
||||
if (isExisting.value) {
|
||||
flushSave()
|
||||
}
|
||||
} else {
|
||||
startEditing()
|
||||
}
|
||||
}
|
||||
|
||||
function startEditing(focus?: 'title' | 'content') {
|
||||
// Capture rendered content height before switching to edit
|
||||
const renderedHeight = renderedContentRef.value?.offsetHeight ?? 0
|
||||
editing.value = true
|
||||
if (focus) {
|
||||
nextTick(() => {
|
||||
if (focus === 'title') {
|
||||
titleInputRef.value?.focus()
|
||||
} else {
|
||||
const el = contentInputRef.value?.$el as HTMLElement | undefined
|
||||
const ta = el?.querySelector('textarea') as HTMLElement | null
|
||||
ta?.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
// Size textarea to match rendered content
|
||||
nextTick(() => {
|
||||
const ta = getTextarea()
|
||||
if (!ta) return
|
||||
const targetHeight = Math.max(renderedHeight, ta.scrollHeight, 80)
|
||||
ta.style.height = Math.min(targetHeight, MAX_TEXTAREA_HEIGHT) + 'px'
|
||||
ta.style.overflowY = targetHeight > MAX_TEXTAREA_HEIGHT ? 'auto' : 'hidden'
|
||||
})
|
||||
}
|
||||
|
||||
// ----- Close -----
|
||||
|
||||
function onClose(v: boolean) {
|
||||
if (!v) {
|
||||
if (isExisting.value) {
|
||||
flushSave()
|
||||
} else {
|
||||
const title = titleValue.value.trim()
|
||||
if (title) {
|
||||
emit('save', {
|
||||
title,
|
||||
content: contentValue.value,
|
||||
color: colorValue.value,
|
||||
})
|
||||
}
|
||||
}
|
||||
emit('update:open', false)
|
||||
}
|
||||
}
|
||||
|
||||
const strings = {
|
||||
titlePlaceholder: t('pantry', 'Note title'),
|
||||
contentPlaceholder: t('pantry', 'Write your note here …'),
|
||||
edit: t('pantry', 'Edit'),
|
||||
view: t('pantry', 'Preview'),
|
||||
untitled: t('pantry', 'Untitled note'),
|
||||
noContent: t('pantry', 'Click to add content …'),
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.note-dialog {
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
&__title-text {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
cursor: text;
|
||||
line-height: 1.3;
|
||||
text-align: start;
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
&__title-input,
|
||||
&__title-input:focus,
|
||||
&__title-input:focus-visible,
|
||||
&__title-input:hover {
|
||||
font-size: 1.3rem !important;
|
||||
font-weight: 600 !important;
|
||||
line-height: 1.3 !important;
|
||||
width: 100% !important;
|
||||
border: 0 !important;
|
||||
border-width: 0 !important;
|
||||
outline: 0 !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
color: inherit !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
&__title-input::placeholder {
|
||||
opacity: 0.5;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&__content-input {
|
||||
:deep(label),
|
||||
:deep(.input-field__label) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
cursor: text;
|
||||
min-height: 100px;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
&__rendered {
|
||||
line-height: 1.6;
|
||||
font-size: 0.95rem;
|
||||
|
||||
:deep(*) {
|
||||
color: inherit !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__empty {
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__color {
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.2);
|
||||
}
|
||||
|
||||
&__swatches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
&__swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
max-width: 24px;
|
||||
max-height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
box-sizing: content-box;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
&--active {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
// Override NC's scoped textarea styles which use [data-v-*] + !important
|
||||
// We match with .textarea__input[data-v-*] to get equal specificity
|
||||
.note-dialog__content-input {
|
||||
.textarea__input[class],
|
||||
.textarea__input[class]:focus,
|
||||
.textarea__input[class]:focus-visible,
|
||||
.textarea__input[class]:focus-within,
|
||||
.textarea__input[class]:hover,
|
||||
.textarea__input[class]:active,
|
||||
.textarea__input[class]:focus-within:not([disabled]),
|
||||
.textarea__input[class]:active:not([disabled]) {
|
||||
border: 0 !important;
|
||||
outline: 0 !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
font-size: 0.95rem !important;
|
||||
line-height: 1.6 !important;
|
||||
color: inherit !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.textarea__input[class]::placeholder {
|
||||
opacity: 0.5;
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.textarea__label {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.textarea__main-wrapper {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
margin-block-start: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Button hover backgrounds inside the note dialog modal
|
||||
.modal-container:has(.note-dialog__body) {
|
||||
.button-vue--vue-tertiary:hover,
|
||||
.button-vue--tertiary:hover,
|
||||
.modal-container__close:hover {
|
||||
background-color: var(--note-btn-hover, rgba(0, 0, 0, 0.08)) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3
src/components/NotesWall/index.ts
Normal file
3
src/components/NotesWall/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as NoteCard } from './NoteCard.vue'
|
||||
export { default as NoteDialog } from './NoteDialog.vue'
|
||||
export { contrastColor, noteColorOptions } from './noteColors'
|
||||
46
src/components/NotesWall/noteColors.test.ts
Normal file
46
src/components/NotesWall/noteColors.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { contrastColor, noteColorOptions } from './noteColors'
|
||||
|
||||
describe('noteColors', () => {
|
||||
describe('contrastColor', () => {
|
||||
it('returns black for light colors', () => {
|
||||
expect(contrastColor('#ffffff')).toBe('#000000')
|
||||
expect(contrastColor('#ffeb3b')).toBe('#000000') // yellow
|
||||
expect(contrastColor('#cddc39')).toBe('#000000') // lime
|
||||
expect(contrastColor('#8bc34a')).toBe('#000000') // light green
|
||||
expect(contrastColor('#ffc107')).toBe('#000000') // amber
|
||||
})
|
||||
|
||||
it('returns white for dark colors', () => {
|
||||
expect(contrastColor('#000000')).toBe('#ffffff')
|
||||
expect(contrastColor('#f44336')).toBe('#ffffff') // red
|
||||
expect(contrastColor('#9c27b0')).toBe('#ffffff') // purple
|
||||
expect(contrastColor('#673ab7')).toBe('#ffffff') // deep purple
|
||||
expect(contrastColor('#3f51b5')).toBe('#ffffff') // indigo
|
||||
})
|
||||
|
||||
it('returns black for orange (bright but saturated)', () => {
|
||||
expect(contrastColor('#ff9800')).toBe('#000000')
|
||||
})
|
||||
|
||||
it('returns white for teal', () => {
|
||||
expect(contrastColor('#009688')).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('returns white for blue', () => {
|
||||
expect(contrastColor('#2196f3')).toBe('#ffffff')
|
||||
})
|
||||
})
|
||||
|
||||
describe('noteColorOptions', () => {
|
||||
it('has 16 color options', () => {
|
||||
expect(noteColorOptions).toHaveLength(16)
|
||||
})
|
||||
|
||||
it('all options are valid hex colors', () => {
|
||||
for (const c of noteColorOptions) {
|
||||
expect(c).toMatch(/^#[0-9a-fA-F]{6}$/)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
26
src/components/NotesWall/noteColors.ts
Normal file
26
src/components/NotesWall/noteColors.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export const noteColorOptions = [
|
||||
'#f44336',
|
||||
'#e91e63',
|
||||
'#9c27b0',
|
||||
'#673ab7',
|
||||
'#3f51b5',
|
||||
'#2196f3',
|
||||
'#03a9f4',
|
||||
'#00bcd4',
|
||||
'#009688',
|
||||
'#4caf50',
|
||||
'#8bc34a',
|
||||
'#cddc39',
|
||||
'#ffeb3b',
|
||||
'#ffc107',
|
||||
'#ff9800',
|
||||
'#ff5722',
|
||||
]
|
||||
|
||||
export function contrastColor(hex: string): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return luminance > 0.5 ? '#000000' : '#ffffff'
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
<template>
|
||||
<NcDialog :name="dialogTitle" :open="open" @update:open="$emit('update:open', $event)">
|
||||
<NcDialog
|
||||
:name="dialogTitle"
|
||||
:open="open"
|
||||
close-on-click-outside
|
||||
@update:open="$emit('update:open', $event)"
|
||||
>
|
||||
<form :id="formId" class="pantry-form" @submit.prevent="submit">
|
||||
<NcTextField
|
||||
v-model="nameValue"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<NcDialog
|
||||
:name="photo.caption ?? strings.preview"
|
||||
:open="open"
|
||||
close-on-click-outside
|
||||
size="large"
|
||||
@update:open="$emit('update:open', $event)"
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<NcDialog
|
||||
:name="strings.title"
|
||||
:open="open"
|
||||
close-on-click-outside
|
||||
size="normal"
|
||||
@update:open="$emit('update:open', $event)"
|
||||
>
|
||||
|
||||
124
src/composables/useNotesWall.test.ts
Normal file
124
src/composables/useNotesWall.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import type { Note } from '@/api/types'
|
||||
|
||||
const mockApi = vi.hoisted(() => ({
|
||||
listNotes: vi.fn(),
|
||||
createNote: vi.fn(),
|
||||
updateNote: vi.fn(),
|
||||
deleteNote: vi.fn(),
|
||||
reorderNotes: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/notes', () => mockApi)
|
||||
|
||||
import { useNotesWall } from './useNotesWall'
|
||||
|
||||
function makeNote(overrides: Partial<Note> = {}): Note {
|
||||
return {
|
||||
id: 1,
|
||||
houseId: 1,
|
||||
title: 'Groceries',
|
||||
content: null,
|
||||
color: null,
|
||||
createdBy: 'admin',
|
||||
sortOrder: 0,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useNotesWall', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('load', () => {
|
||||
it('loads notes', async () => {
|
||||
const notes = [makeNote({ id: 1 }), makeNote({ id: 2 })]
|
||||
mockApi.listNotes.mockResolvedValue(notes)
|
||||
|
||||
const wall = useNotesWall(1)
|
||||
await wall.load()
|
||||
|
||||
expect(wall.notes.value).toEqual(notes)
|
||||
expect(wall.loading.value).toBe(false)
|
||||
expect(wall.error.value).toBeNull()
|
||||
})
|
||||
|
||||
it('sets error on failure', async () => {
|
||||
mockApi.listNotes.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const wall = useNotesWall(1)
|
||||
await wall.load()
|
||||
|
||||
expect(wall.error.value).toBe('Network error')
|
||||
expect(wall.loading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('create', () => {
|
||||
it('creates and appends to list', async () => {
|
||||
mockApi.listNotes.mockResolvedValue([])
|
||||
const newNote = makeNote({ id: 10 })
|
||||
mockApi.createNote.mockResolvedValue(newNote)
|
||||
|
||||
const wall = useNotesWall(1)
|
||||
await wall.load()
|
||||
const result = await wall.create('New Note', 'content', '#ff0000')
|
||||
|
||||
expect(mockApi.createNote).toHaveBeenCalledWith(1, 'New Note', 'content', '#ff0000')
|
||||
expect(result).toEqual(newNote)
|
||||
expect(wall.notes.value).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('update', () => {
|
||||
it('updates note in list', async () => {
|
||||
const original = makeNote({ id: 1, title: 'Old' })
|
||||
const updated = makeNote({ id: 1, title: 'New' })
|
||||
mockApi.listNotes.mockResolvedValue([original])
|
||||
mockApi.updateNote.mockResolvedValue(updated)
|
||||
|
||||
const wall = useNotesWall(1)
|
||||
await wall.load()
|
||||
await wall.update(1, { title: 'New' })
|
||||
|
||||
expect(wall.notes.value[0].title).toBe('New')
|
||||
})
|
||||
})
|
||||
|
||||
describe('remove', () => {
|
||||
it('removes note from list', async () => {
|
||||
mockApi.listNotes.mockResolvedValue([makeNote({ id: 1 }), makeNote({ id: 2 })])
|
||||
mockApi.deleteNote.mockResolvedValue(undefined)
|
||||
|
||||
const wall = useNotesWall(1)
|
||||
await wall.load()
|
||||
await wall.remove(1)
|
||||
|
||||
expect(wall.notes.value).toHaveLength(1)
|
||||
expect(wall.notes.value[0].id).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reorder', () => {
|
||||
it('updates sort orders locally and sorts', async () => {
|
||||
mockApi.listNotes.mockResolvedValue([
|
||||
makeNote({ id: 1, sortOrder: 0 }),
|
||||
makeNote({ id: 2, sortOrder: 1 }),
|
||||
])
|
||||
mockApi.reorderNotes.mockResolvedValue(undefined)
|
||||
|
||||
const wall = useNotesWall(1)
|
||||
await wall.load()
|
||||
await wall.reorder([
|
||||
{ id: 2, sortOrder: 0 },
|
||||
{ id: 1, sortOrder: 1 },
|
||||
])
|
||||
|
||||
expect(wall.notes.value[0].id).toBe(2)
|
||||
expect(wall.notes.value[1].id).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
54
src/composables/useNotesWall.ts
Normal file
54
src/composables/useNotesWall.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { ref } from 'vue'
|
||||
import * as api from '@/api/notes'
|
||||
import type { Note } from '@/api/types'
|
||||
|
||||
export function useNotesWall(houseId: number) {
|
||||
const notes = ref<Note[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
notes.value = await api.listNotes(houseId)
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create(
|
||||
title: string,
|
||||
content?: string | null,
|
||||
color?: string | null,
|
||||
): Promise<Note> {
|
||||
const created = await api.createNote(houseId, title, content, color)
|
||||
notes.value = [...notes.value, created]
|
||||
return created
|
||||
}
|
||||
|
||||
async function update(
|
||||
noteId: number,
|
||||
patch: Parameters<typeof api.updateNote>[2],
|
||||
): Promise<void> {
|
||||
const updated = await api.updateNote(houseId, noteId, patch)
|
||||
notes.value = notes.value.map((n) => (n.id === noteId ? updated : n))
|
||||
}
|
||||
|
||||
async function remove(noteId: number): Promise<void> {
|
||||
await api.deleteNote(houseId, noteId)
|
||||
notes.value = notes.value.filter((n) => n.id !== noteId)
|
||||
}
|
||||
|
||||
async function reorder(items: { id: number; sortOrder: number }[]): Promise<void> {
|
||||
await api.reorderNotes(houseId, items)
|
||||
const map = new Map(items.map((i) => [i.id, i.sortOrder]))
|
||||
notes.value = notes.value
|
||||
.map((n) => (map.has(n.id) ? { ...n, sortOrder: map.get(n.id)! } : n))
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
}
|
||||
|
||||
return { notes, loading, error, load, create, update, remove, reorder }
|
||||
}
|
||||
@@ -57,7 +57,8 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'notes',
|
||||
name: 'notes',
|
||||
component: () => import('@/views/NotesWallStub.vue'),
|
||||
component: () => import('@/views/NotesWallView.vue'),
|
||||
props: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
294
src/views/NotesWallView.vue
Normal file
294
src/views/NotesWallView.vue
Normal file
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<div ref="wallRef" class="pantry-notes">
|
||||
<PageToolbar :title="strings.title">
|
||||
<template #actions>
|
||||
<NcButton variant="primary" @click="openCreateDialog">
|
||||
<template #icon><PlusIcon :size="20" /></template>
|
||||
{{ strings.newNote }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</PageToolbar>
|
||||
|
||||
<div class="pantry-notes__body">
|
||||
<div v-if="loading" class="pantry-center">
|
||||
<NcLoadingIcon :size="36" />
|
||||
</div>
|
||||
|
||||
<NcEmptyContent
|
||||
v-else-if="notes.length === 0"
|
||||
:name="strings.emptyTitle"
|
||||
:description="strings.emptyBody"
|
||||
>
|
||||
<template #icon>
|
||||
<NoteIcon />
|
||||
</template>
|
||||
<template #action>
|
||||
<NcButton variant="primary" @click="openCreateDialog">
|
||||
{{ strings.newNote }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<div v-else class="pantry-notes__grid">
|
||||
<template v-for="item in gridItems" :key="item.key">
|
||||
<div
|
||||
v-if="item.type === 'placeholder'"
|
||||
class="pantry-notes__placeholder"
|
||||
@dragover.prevent
|
||||
@drop.prevent.stop="onPlaceholderDrop"
|
||||
/>
|
||||
<NoteCard
|
||||
v-else
|
||||
:note="item.note"
|
||||
@edit="openEditDialog"
|
||||
@delete="confirmDelete"
|
||||
@drag-start="onDragStart"
|
||||
@reorder-over="onReorderOver"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit dialog -->
|
||||
<NoteDialog
|
||||
v-if="showDialog"
|
||||
:open="showDialog"
|
||||
:note="editingNote"
|
||||
@update:open="closeDialog"
|
||||
@save="submitDialog"
|
||||
/>
|
||||
|
||||
<!-- Delete confirm -->
|
||||
<NcDialog
|
||||
v-if="deletingNote"
|
||||
:name="strings.deleteTitle"
|
||||
:open="!!deletingNote"
|
||||
close-on-click-outside
|
||||
@update:open="(v) => !v && (deletingNote = null)"
|
||||
>
|
||||
<p>{{ deleteBody }}</p>
|
||||
<template #actions>
|
||||
<NcButton @click="deletingNote = null">{{ strings.cancel }}</NcButton>
|
||||
<NcButton variant="error" @click="submitDelete">{{ strings.delete }}</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcDialog from '@nextcloud/vue/components/NcDialog'
|
||||
import PageToolbar from '@/components/PageToolbar'
|
||||
import { NoteCard, NoteDialog } from '@/components/NotesWall'
|
||||
import PlusIcon from '@icons/Plus.vue'
|
||||
import NoteIcon from '@icons/Note.vue'
|
||||
import type { Note } from '@/api/types'
|
||||
import { useNotesWall } from '@/composables/useNotesWall'
|
||||
|
||||
const props = defineProps<{ houseId: string }>()
|
||||
|
||||
const houseIdNum = computed(() => Number(props.houseId))
|
||||
const { notes, loading, load, create, update, remove, reorder } = useNotesWall(houseIdNum.value)
|
||||
|
||||
onMounted(load)
|
||||
watch(
|
||||
() => props.houseId,
|
||||
() => load(),
|
||||
)
|
||||
|
||||
// ----- Reorder -----
|
||||
|
||||
type GridItem = { type: 'note'; key: string; note: Note } | { type: 'placeholder'; key: string }
|
||||
|
||||
const draggingNoteId = ref<number | null>(null)
|
||||
const dropIndex = ref<number | null>(null)
|
||||
const wallRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function buildGridItems(): GridItem[] {
|
||||
const dragId = draggingNoteId.value
|
||||
if (dragId === null || dropIndex.value === null) {
|
||||
return notes.value.map((n) => ({ type: 'note' as const, key: 'n-' + n.id, note: n }))
|
||||
}
|
||||
|
||||
const without = notes.value.filter((n) => n.id !== dragId)
|
||||
const items: GridItem[] = without.map((n) => ({
|
||||
type: 'note' as const,
|
||||
key: 'n-' + n.id,
|
||||
note: n,
|
||||
}))
|
||||
const clampedIndex = Math.min(dropIndex.value, items.length)
|
||||
items.splice(clampedIndex, 0, { type: 'placeholder', key: 'drop-placeholder' })
|
||||
return items
|
||||
}
|
||||
|
||||
const gridItems = computed(() => buildGridItems())
|
||||
|
||||
function onDragStart(noteId: number) {
|
||||
draggingNoteId.value = noteId
|
||||
dropIndex.value = null
|
||||
}
|
||||
|
||||
function onReorderOver(hoveredNoteId: number, e: MouseEvent) {
|
||||
const dragId = draggingNoteId.value
|
||||
if (!dragId || dragId === hoveredNoteId) return
|
||||
|
||||
const without = notes.value.filter((n) => n.id !== dragId)
|
||||
const idx = without.findIndex((n) => n.id === hoveredNoteId)
|
||||
if (idx === -1) return
|
||||
|
||||
const target = e.currentTarget as HTMLElement | null
|
||||
if (target) {
|
||||
const rect = target.getBoundingClientRect()
|
||||
const past = e.clientX > rect.left + rect.width / 2
|
||||
dropIndex.value = past ? idx + 1 : idx
|
||||
} else {
|
||||
dropIndex.value = idx
|
||||
}
|
||||
}
|
||||
|
||||
function onPlaceholderDrop() {
|
||||
commitReorder()
|
||||
}
|
||||
|
||||
async function commitReorder() {
|
||||
const dragId = draggingNoteId.value
|
||||
const idx = dropIndex.value
|
||||
draggingNoteId.value = null
|
||||
dropIndex.value = null
|
||||
|
||||
if (dragId === null || idx === null) return
|
||||
|
||||
const dragged = notes.value.find((n) => n.id === dragId)
|
||||
if (!dragged) return
|
||||
|
||||
const without = notes.value.filter((n) => n.id !== dragId)
|
||||
const clampedIndex = Math.min(idx, without.length)
|
||||
const reordered = [...without]
|
||||
reordered.splice(clampedIndex, 0, dragged)
|
||||
|
||||
const items = reordered.map((n, i) => ({ id: n.id, sortOrder: i }))
|
||||
await reorder(items)
|
||||
}
|
||||
|
||||
// Capture-phase listeners
|
||||
function onDropCapture() {
|
||||
draggingNoteId.value = null
|
||||
dropIndex.value = null
|
||||
}
|
||||
onMounted(() => {
|
||||
wallRef.value?.addEventListener('drop', onDropCapture, true)
|
||||
wallRef.value?.addEventListener('dragend', onDropCapture, true)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
wallRef.value?.removeEventListener('drop', onDropCapture, true)
|
||||
wallRef.value?.removeEventListener('dragend', onDropCapture, true)
|
||||
})
|
||||
|
||||
// ----- Create / Edit -----
|
||||
|
||||
const showDialog = ref(false)
|
||||
const editingNote = ref<Note | null>(null)
|
||||
|
||||
function openCreateDialog() {
|
||||
editingNote.value = null
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(note: Note) {
|
||||
editingNote.value = note
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
function closeDialog(v: boolean) {
|
||||
if (!v) {
|
||||
showDialog.value = false
|
||||
editingNote.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDialog(data: { title: string; content: string; color: string }) {
|
||||
if (editingNote.value) {
|
||||
await update(editingNote.value.id, {
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
color: data.color,
|
||||
})
|
||||
// Update local ref so subsequent saves use the latest state
|
||||
editingNote.value = {
|
||||
...editingNote.value,
|
||||
title: data.title,
|
||||
content: data.content || null,
|
||||
color: data.color || null,
|
||||
}
|
||||
} else {
|
||||
const created = await create(data.title, data.content || null, data.color || null)
|
||||
// Switch to editing the newly created note
|
||||
editingNote.value = created
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Delete -----
|
||||
|
||||
const deletingNote = ref<Note | null>(null)
|
||||
const deleteBody = computed(() =>
|
||||
t('pantry', 'Are you sure you want to delete "{name}"?', {
|
||||
name: deletingNote.value?.title ?? '',
|
||||
}),
|
||||
)
|
||||
|
||||
function confirmDelete(note: Note) {
|
||||
deletingNote.value = note
|
||||
}
|
||||
|
||||
async function submitDelete() {
|
||||
if (!deletingNote.value) return
|
||||
await remove(deletingNote.value.id)
|
||||
deletingNote.value = null
|
||||
}
|
||||
|
||||
const strings = {
|
||||
title: t('pantry', 'Notes wall'),
|
||||
newNote: t('pantry', 'New note'),
|
||||
cancel: t('pantry', 'Cancel'),
|
||||
delete: t('pantry', 'Delete'),
|
||||
emptyTitle: t('pantry', 'No notes yet'),
|
||||
emptyBody: t('pantry', 'Create your first note to start sharing reminders with your household.'),
|
||||
deleteTitle: t('pantry', 'Delete note'),
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pantry-notes {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
|
||||
&__body {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 0 1rem 1rem;
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
min-height: 80px;
|
||||
border: 3px dashed var(--color-primary-element);
|
||||
border-radius: var(--border-radius-large, 12px);
|
||||
background: rgba(var(--color-primary-element-rgb, 0, 120, 212), 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.pantry-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -158,6 +158,7 @@
|
||||
v-if="editingPhoto"
|
||||
:name="strings.editPhotoTitle"
|
||||
:open="!!editingPhoto"
|
||||
close-on-click-outside
|
||||
@update:open="(v) => !v && (editingPhoto = null)"
|
||||
>
|
||||
<form id="pantry-edit-photo-form" class="pantry-form" @submit.prevent="submitEditPhoto">
|
||||
@@ -180,6 +181,7 @@
|
||||
v-if="deletingPhoto"
|
||||
:name="strings.deletePhotoTitle"
|
||||
:open="!!deletingPhoto"
|
||||
close-on-click-outside
|
||||
@update:open="(v) => !v && (deletingPhoto = null)"
|
||||
>
|
||||
<p>{{ strings.deletePhotoBody }}</p>
|
||||
@@ -194,6 +196,7 @@
|
||||
v-if="deletingFolder"
|
||||
:name="strings.deleteFolderTitle"
|
||||
:open="!!deletingFolder"
|
||||
close-on-click-outside
|
||||
@update:open="(v) => !v && (deletingFolder = null)"
|
||||
>
|
||||
<p>{{ deleteFolderBody }}</p>
|
||||
|
||||
@@ -131,6 +131,7 @@
|
||||
v-if="editing"
|
||||
:name="strings.editDialogTitle"
|
||||
:open="!!editing"
|
||||
close-on-click-outside
|
||||
@update:open="(v) => !v && (editing = null)"
|
||||
>
|
||||
<form id="pantry-edit-item-form" class="pantry-form" @submit.prevent="submitEdit">
|
||||
@@ -222,6 +223,7 @@
|
||||
v-if="previewing"
|
||||
:name="previewing.name"
|
||||
:open="!!previewing"
|
||||
close-on-click-outside
|
||||
size="large"
|
||||
@update:open="(v) => !v && (previewing = null)"
|
||||
>
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
v-if="showCreate"
|
||||
:name="strings.createDialogTitle"
|
||||
:open="showCreate"
|
||||
close-on-click-outside
|
||||
@update:open="showCreate = $event"
|
||||
>
|
||||
<form id="pantry-create-list-form" class="pantry-form" @submit.prevent="submitCreate">
|
||||
@@ -95,6 +96,7 @@
|
||||
v-if="editing"
|
||||
:name="strings.editDialogTitle"
|
||||
:open="!!editing"
|
||||
close-on-click-outside
|
||||
@update:open="(v) => !v && (editing = null)"
|
||||
>
|
||||
<form class="pantry-form" @submit.prevent="submitEdit">
|
||||
@@ -121,6 +123,7 @@
|
||||
v-if="deleting"
|
||||
:name="strings.deleteDialogTitle"
|
||||
:open="!!deleting"
|
||||
close-on-click-outside
|
||||
@update:open="(v) => !v && (deleting = null)"
|
||||
>
|
||||
<p>{{ deleteConfirmBody }}</p>
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
v-if="showCreate"
|
||||
:name="strings.createDialogTitle"
|
||||
:open="showCreate"
|
||||
close-on-click-outside
|
||||
@update:open="showCreate = $event"
|
||||
>
|
||||
<form id="pantry-create-house-form" class="pantry-create-form" @submit.prevent="submitCreate">
|
||||
|
||||
192
tests/unit/Service/NotesWallServiceTest.php
Normal file
192
tests/unit/Service/NotesWallServiceTest.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Tests\Unit\Service;
|
||||
|
||||
use OCA\Pantry\Db\Note;
|
||||
use OCA\Pantry\Db\NoteMapper;
|
||||
use OCA\Pantry\Exception\NotFoundException;
|
||||
use OCA\Pantry\Service\NotesWallService;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class NotesWallServiceTest extends TestCase {
|
||||
/** @var NoteMapper&MockObject */
|
||||
private NoteMapper $noteMapper;
|
||||
private NotesWallService $svc;
|
||||
|
||||
protected function setUp(): void {
|
||||
$this->noteMapper = $this->createMock(NoteMapper::class);
|
||||
$this->svc = new NotesWallService($this->noteMapper);
|
||||
}
|
||||
|
||||
private function makeNote(array $overrides = []): Note {
|
||||
$n = new Note();
|
||||
$n->setHouseId($overrides['houseId'] ?? 1);
|
||||
$n->setTitle($overrides['title'] ?? 'Groceries');
|
||||
$n->setContent($overrides['content'] ?? null);
|
||||
$n->setColor($overrides['color'] ?? null);
|
||||
$n->setCreatedBy($overrides['createdBy'] ?? 'admin');
|
||||
$n->setSortOrder($overrides['sortOrder'] ?? 0);
|
||||
$n->setCreatedAt($overrides['createdAt'] ?? 1000);
|
||||
$n->setUpdatedAt($overrides['updatedAt'] ?? 1000);
|
||||
if (isset($overrides['id'])) {
|
||||
$ref = new \ReflectionProperty($n, 'id');
|
||||
$ref->setValue($n, $overrides['id']);
|
||||
}
|
||||
return $n;
|
||||
}
|
||||
|
||||
public function testListNotesDelegatesToMapper(): void {
|
||||
$notes = [$this->makeNote()];
|
||||
$this->noteMapper->expects($this->once())
|
||||
->method('findByHouse')
|
||||
->with(1)
|
||||
->willReturn($notes);
|
||||
|
||||
$this->assertSame($notes, $this->svc->listNotes(1));
|
||||
}
|
||||
|
||||
public function testGetNoteThrowsNotFoundWhenMissing(): void {
|
||||
$this->noteMapper->method('findById')
|
||||
->willThrowException(new DoesNotExistException(''));
|
||||
|
||||
$this->expectException(NotFoundException::class);
|
||||
$this->svc->getNote(999);
|
||||
}
|
||||
|
||||
public function testCreateNoteSetsFieldsAndInserts(): void {
|
||||
$this->noteMapper->expects($this->once())
|
||||
->method('insert')
|
||||
->with($this->callback(function (Note $n) {
|
||||
return $n->getHouseId() === 1
|
||||
&& $n->getTitle() === 'My Note'
|
||||
&& $n->getContent() === 'Some content'
|
||||
&& $n->getColor() === '#ff0000'
|
||||
&& $n->getCreatedBy() === 'alice'
|
||||
&& $n->getCreatedAt() > 0;
|
||||
}))
|
||||
->willReturnArgument(0);
|
||||
|
||||
$result = $this->svc->createNote(1, 'alice', ' My Note ', 'Some content', '#ff0000');
|
||||
$this->assertSame('My Note', $result->getTitle());
|
||||
}
|
||||
|
||||
public function testCreateNoteRejectsEmptyTitle(): void {
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->svc->createNote(1, 'alice', ' ', null, null);
|
||||
}
|
||||
|
||||
public function testCreateNoteRejectsInvalidColor(): void {
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->svc->createNote(1, 'alice', 'Test', null, 'red');
|
||||
}
|
||||
|
||||
public function testCreateNoteAllowsNullColor(): void {
|
||||
$this->noteMapper->expects($this->once())
|
||||
->method('insert')
|
||||
->with($this->callback(fn (Note $n) => $n->getColor() === null))
|
||||
->willReturnArgument(0);
|
||||
|
||||
$this->svc->createNote(1, 'alice', 'Test', null, null);
|
||||
}
|
||||
|
||||
public function testUpdateNotePatchesTitleContentColor(): void {
|
||||
$note = $this->makeNote(['id' => 1]);
|
||||
$this->noteMapper->method('findById')->willReturn($note);
|
||||
$this->noteMapper->expects($this->once())->method('update');
|
||||
|
||||
$result = $this->svc->updateNote(1, [
|
||||
'title' => 'New Title',
|
||||
'content' => 'New content',
|
||||
'color' => '#00ff00',
|
||||
]);
|
||||
$this->assertSame('New Title', $result->getTitle());
|
||||
$this->assertSame('New content', $result->getContent());
|
||||
$this->assertSame('#00ff00', $result->getColor());
|
||||
}
|
||||
|
||||
public function testUpdateNoteRejectsEmptyTitle(): void {
|
||||
$note = $this->makeNote(['id' => 1]);
|
||||
$this->noteMapper->method('findById')->willReturn($note);
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->svc->updateNote(1, ['title' => ' ']);
|
||||
}
|
||||
|
||||
public function testUpdateNoteRejectsInvalidColor(): void {
|
||||
$note = $this->makeNote(['id' => 1]);
|
||||
$this->noteMapper->method('findById')->willReturn($note);
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->svc->updateNote(1, ['color' => 'nope']);
|
||||
}
|
||||
|
||||
public function testUpdateNoteClearsContentWithEmptyString(): void {
|
||||
$note = $this->makeNote(['id' => 1, 'content' => 'Old']);
|
||||
$this->noteMapper->method('findById')->willReturn($note);
|
||||
$this->noteMapper->expects($this->once())->method('update');
|
||||
|
||||
$result = $this->svc->updateNote(1, ['content' => '']);
|
||||
$this->assertNull($result->getContent());
|
||||
}
|
||||
|
||||
public function testUpdateNoteClearsColorWithEmptyString(): void {
|
||||
$note = $this->makeNote(['id' => 1, 'color' => '#ff0000']);
|
||||
$this->noteMapper->method('findById')->willReturn($note);
|
||||
$this->noteMapper->expects($this->once())->method('update');
|
||||
|
||||
$result = $this->svc->updateNote(1, ['color' => '']);
|
||||
$this->assertNull($result->getColor());
|
||||
}
|
||||
|
||||
public function testUpdateNoteSortOrder(): void {
|
||||
$note = $this->makeNote(['id' => 1, 'sortOrder' => 0]);
|
||||
$this->noteMapper->method('findById')->willReturn($note);
|
||||
$this->noteMapper->expects($this->once())->method('update');
|
||||
|
||||
$result = $this->svc->updateNote(1, ['sortOrder' => 5]);
|
||||
$this->assertSame(5, $result->getSortOrder());
|
||||
}
|
||||
|
||||
public function testDeleteNoteRemovesFromMapper(): void {
|
||||
$note = $this->makeNote(['id' => 1]);
|
||||
$this->noteMapper->method('findById')->willReturn($note);
|
||||
$this->noteMapper->expects($this->once())
|
||||
->method('delete')
|
||||
->with($note);
|
||||
|
||||
$this->svc->deleteNote(1);
|
||||
}
|
||||
|
||||
public function testReorderNotesUpdatesMatchingItems(): void {
|
||||
$n1 = $this->makeNote(['id' => 1, 'houseId' => 1]);
|
||||
$n2 = $this->makeNote(['id' => 2, 'houseId' => 1]);
|
||||
$foreign = $this->makeNote(['id' => 3, 'houseId' => 99]);
|
||||
|
||||
$this->noteMapper->method('findById')->willReturnCallback(function (int $id) use ($n1, $n2, $foreign) {
|
||||
return match ($id) {
|
||||
1 => $n1,
|
||||
2 => $n2,
|
||||
3 => $foreign,
|
||||
default => throw new DoesNotExistException(''),
|
||||
};
|
||||
});
|
||||
|
||||
$this->noteMapper->expects($this->exactly(2))->method('update');
|
||||
|
||||
$this->svc->reorderNotes(1, [
|
||||
['id' => 2, 'sortOrder' => 0],
|
||||
['id' => 1, 'sortOrder' => 1],
|
||||
['id' => 3, 'sortOrder' => 2], // wrong house
|
||||
]);
|
||||
|
||||
$this->assertSame(1, $n1->getSortOrder());
|
||||
$this->assertSame(0, $n2->getSortOrder());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user