feat: notes wall

This commit is contained in:
2026-04-06 16:16:49 +03:00
parent 68300940d1
commit 8403ee0aa4
31 changed files with 3116 additions and 2 deletions

View 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
View 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
View 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();
}
}

View File

@@ -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;
}
}

View File

@@ -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,

View File

@@ -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();

View 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)');
}
}
}

View File

@@ -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
View 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 })
}

View File

@@ -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

View File

@@ -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">

View File

@@ -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>

View 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')
})
})
})

View 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>

View 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)
})
})
})

View 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>

View File

@@ -0,0 +1,3 @@
export { default as NoteCard } from './NoteCard.vue'
export { default as NoteDialog } from './NoteDialog.vue'
export { contrastColor, noteColorOptions } from './noteColors'

View 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}$/)
}
})
})
})

View 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'
}

View File

@@ -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"

View File

@@ -2,6 +2,7 @@
<NcDialog
:name="photo.caption ?? strings.preview"
:open="open"
close-on-click-outside
size="large"
@update:open="$emit('update:open', $event)"
>

View File

@@ -2,6 +2,7 @@
<NcDialog
:name="strings.title"
:open="open"
close-on-click-outside
size="normal"
@update:open="$emit('update:open', $event)"
>

View 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)
})
})
})

View 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 }
}

View File

@@ -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
View 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>

View File

@@ -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>

View File

@@ -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)"
>

View File

@@ -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>

View File

@@ -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">

View 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());
}
}