mirror of
https://github.com/chenasraf/nextcloud-pantry.git
synced 2026-05-17 17:28:01 +00:00
feat: support checklist item sorting/reordering
This commit is contained in:
@@ -178,6 +178,7 @@ final class ChecklistController extends OCSController {
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param int $listId List id.
|
||||
* @param string $sortBy Sort mode (custom, newest, oldest, name_asc, name_desc).
|
||||
* @param int<1, 1000> $limit Maximum number of items to return.
|
||||
* @param int<0, max> $offset Number of items to skip.
|
||||
*
|
||||
@@ -187,12 +188,12 @@ final class ChecklistController extends OCSController {
|
||||
*/
|
||||
#[ApiRoute(verb: 'GET', url: '/api/houses/{houseId}/lists/{listId}/items')]
|
||||
#[NoAdminRequired]
|
||||
public function indexItems(int $houseId, int $listId, int $limit = 200, int $offset = 0): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $listId, $limit, $offset): DataResponse {
|
||||
public function indexItems(int $houseId, int $listId, string $sortBy = 'custom', int $limit = 200, int $offset = 0): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $listId, $sortBy, $limit, $offset): DataResponse {
|
||||
$this->auth->requireMember($houseId, $this->requireUid());
|
||||
$list = $this->lists->getList($listId);
|
||||
$this->assertListInHouse($list->getHouseId(), $houseId);
|
||||
$all = $this->lists->listItems($listId);
|
||||
$all = $this->lists->listItems($listId, $sortBy);
|
||||
$sliced = array_slice($all, max(0, $offset), max(0, $limit));
|
||||
$items = array_map(fn ($i) => $i->jsonSerialize(), $sliced);
|
||||
return new DataResponse($items);
|
||||
@@ -385,6 +386,29 @@ final class ChecklistController extends OCSController {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch reorder items in a list
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param int $listId List id.
|
||||
* @param list<array{id: int, sortOrder: int}> $items Reorder entries.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantrySuccess, array{}>
|
||||
*
|
||||
* 200: Items reordered
|
||||
*/
|
||||
#[ApiRoute(verb: 'POST', url: '/api/houses/{houseId}/lists/{listId}/items/reorder')]
|
||||
#[NoAdminRequired]
|
||||
public function reorderItems(int $houseId, int $listId, array $items = []): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $listId, $items): DataResponse {
|
||||
$this->auth->requireMember($houseId, $this->requireUid());
|
||||
$list = $this->lists->getList($listId);
|
||||
$this->assertListInHouse($list->getHouseId(), $houseId);
|
||||
$this->lists->reorderItems($listId, $items);
|
||||
return new DataResponse(['success' => true]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an image for an item
|
||||
*
|
||||
|
||||
@@ -219,6 +219,48 @@ final class PrefsController extends OCSController {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get checklist item sort preference for a house
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, array{sort: string}, array{}>
|
||||
*
|
||||
* 200: Sort preference returned
|
||||
*/
|
||||
#[ApiRoute(verb: 'GET', url: '/api/houses/{houseId}/prefs/checklist-item-sort')]
|
||||
#[NoAdminRequired]
|
||||
public function getChecklistItemSort(int $houseId): DataResponse {
|
||||
return $this->runAction(function () use ($houseId): DataResponse {
|
||||
$uid = $this->requireUid();
|
||||
$this->auth->requireMember($houseId, $uid);
|
||||
return new DataResponse([
|
||||
'sort' => $this->prefs->getChecklistItemSort($uid, $houseId),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set checklist item sort preference for a house
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param string $sort Sort mode.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, array{sort: string}, array{}>
|
||||
*
|
||||
* 200: Sort preference updated
|
||||
*/
|
||||
#[ApiRoute(verb: 'PUT', url: '/api/houses/{houseId}/prefs/checklist-item-sort')]
|
||||
#[NoAdminRequired]
|
||||
public function setChecklistItemSort(int $houseId, string $sort): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $sort): DataResponse {
|
||||
$uid = $this->requireUid();
|
||||
$this->auth->requireMember($houseId, $uid);
|
||||
$stored = $this->prefs->setChecklistItemSort($uid, $houseId, $sort);
|
||||
return new DataResponse(['sort' => $stored]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notification preferences for a house
|
||||
*
|
||||
|
||||
@@ -24,13 +24,32 @@ class ChecklistItemMapper extends QBMapper {
|
||||
/**
|
||||
* @return ChecklistItem[]
|
||||
*/
|
||||
public function findByList(int $listId): array {
|
||||
public function findByList(int $listId, string $sortBy = 'custom'): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('list_id', $qb->createNamedParameter($listId, IQueryBuilder::PARAM_INT)))
|
||||
->orderBy('sort_order', 'ASC')
|
||||
->addOrderBy('created_at', 'ASC');
|
||||
->where($qb->expr()->eq('list_id', $qb->createNamedParameter($listId, IQueryBuilder::PARAM_INT)));
|
||||
|
||||
switch ($sortBy) {
|
||||
case 'newest':
|
||||
$qb->orderBy('created_at', 'DESC');
|
||||
break;
|
||||
case 'oldest':
|
||||
$qb->orderBy('created_at', 'ASC');
|
||||
break;
|
||||
case 'name_asc':
|
||||
$qb->orderBy('name', 'ASC')
|
||||
->addOrderBy('created_at', 'ASC');
|
||||
break;
|
||||
case 'name_desc':
|
||||
$qb->orderBy('name', 'DESC')
|
||||
->addOrderBy('created_at', 'ASC');
|
||||
break;
|
||||
default: // custom
|
||||
$qb->orderBy('sort_order', 'ASC')
|
||||
->addOrderBy('created_at', 'ASC');
|
||||
break;
|
||||
}
|
||||
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
@@ -100,10 +100,10 @@ class ChecklistService {
|
||||
*
|
||||
* @return ChecklistItem[]
|
||||
*/
|
||||
public function listItems(int $listId, ?int $now = null): array {
|
||||
public function listItems(int $listId, string $sortBy = 'custom', ?int $now = null): array {
|
||||
// Eagerly reopen any due recurring items in this list before returning.
|
||||
$this->reopenDueItems($now);
|
||||
return $this->itemMapper->findByList($listId);
|
||||
return $this->itemMapper->findByList($listId, $sortBy);
|
||||
}
|
||||
|
||||
public function getItem(int $itemId): ChecklistItem {
|
||||
@@ -215,6 +215,33 @@ class ChecklistService {
|
||||
return $item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch reorder items within a list.
|
||||
*
|
||||
* @param int $listId List id.
|
||||
* @param array<array{id: int, sortOrder: int}> $items Reorder entries.
|
||||
*/
|
||||
public function reorderItems(int $listId, array $items): void {
|
||||
foreach ($items as $entry) {
|
||||
$id = (int)($entry['id'] ?? 0);
|
||||
$sortOrder = (int)($entry['sortOrder'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$item = $this->itemMapper->findById($id);
|
||||
} catch (DoesNotExistException) {
|
||||
continue;
|
||||
}
|
||||
if ($item->getListId() !== $listId) {
|
||||
continue;
|
||||
}
|
||||
$item->setSortOrder($sortOrder);
|
||||
$item->setUpdatedAt(time());
|
||||
$this->itemMapper->update($item);
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleItem(int $itemId, string $uid, ?int $now = null): ChecklistItem {
|
||||
$item = $this->getItem($itemId);
|
||||
$now ??= time();
|
||||
|
||||
@@ -107,6 +107,28 @@ class PrefsService {
|
||||
return $sort;
|
||||
}
|
||||
|
||||
// ----- Checklist item sort preferences -----
|
||||
|
||||
private const KEY_CHECKLIST_ITEM_SORT = 'checklist_item_sort';
|
||||
|
||||
public function getChecklistItemSort(string $uid, int $houseId): string {
|
||||
return $this->config->getUserValue(
|
||||
$uid,
|
||||
Application::APP_ID,
|
||||
self::KEY_CHECKLIST_ITEM_SORT . '_' . $houseId,
|
||||
'custom',
|
||||
);
|
||||
}
|
||||
|
||||
public function setChecklistItemSort(string $uid, int $houseId, string $sort): string {
|
||||
$allowed = ['custom', 'newest', 'oldest', 'name_asc', 'name_desc'];
|
||||
if (!in_array($sort, $allowed, true)) {
|
||||
$sort = 'custom';
|
||||
}
|
||||
$this->config->setUserValue($uid, Application::APP_ID, self::KEY_CHECKLIST_ITEM_SORT . '_' . $houseId, $sort);
|
||||
return $sort;
|
||||
}
|
||||
|
||||
// ----- Notification preferences -----
|
||||
|
||||
public function getNotificationPref(string $uid, int $houseId, string $prefKey): bool {
|
||||
|
||||
383
openapi.json
383
openapi.json
@@ -1659,6 +1659,15 @@
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sortBy",
|
||||
"in": "query",
|
||||
"description": "Sort mode (custom, newest, oldest, name_asc, name_desc).",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
@@ -2339,6 +2348,149 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/pantry/api/houses/{houseId}/lists/{listId}/items/reorder": {
|
||||
"post": {
|
||||
"operationId": "checklist-reorder-items",
|
||||
"summary": "Batch reorder items in a list",
|
||||
"tags": [
|
||||
"checklist"
|
||||
],
|
||||
"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": "listId",
|
||||
"in": "path",
|
||||
"description": "List 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": "Items 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}/lists/{listId}/items/{itemId}/image": {
|
||||
"post": {
|
||||
"operationId": "checklist-upload-item-image",
|
||||
@@ -6787,6 +6939,237 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/pantry/api/houses/{houseId}/prefs/checklist-item-sort": {
|
||||
"get": {
|
||||
"operationId": "prefs-get-checklist-item-sort",
|
||||
"summary": "Get checklist item sort preference for a house",
|
||||
"tags": [
|
||||
"prefs"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"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": "Sort preference returned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"sort"
|
||||
],
|
||||
"properties": {
|
||||
"sort": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"operationId": "prefs-set-checklist-item-sort",
|
||||
"summary": "Set checklist item sort preference for a house",
|
||||
"tags": [
|
||||
"prefs"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"sort"
|
||||
],
|
||||
"properties": {
|
||||
"sort": {
|
||||
"type": "string",
|
||||
"description": "Sort mode."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "Sort preference updated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"sort"
|
||||
],
|
||||
"properties": {
|
||||
"sort": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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}/prefs/notifications": {
|
||||
"get": {
|
||||
"operationId": "prefs-get-notification-prefs",
|
||||
|
||||
@@ -38,8 +38,14 @@ export async function deleteList(houseId: number, listId: number): Promise<void>
|
||||
await ocs.delete(`/houses/${houseId}/lists/${listId}`)
|
||||
}
|
||||
|
||||
export async function listItems(houseId: number, listId: number): Promise<ChecklistItem[]> {
|
||||
const resp = await ocs.get<ChecklistItem[]>(`/houses/${houseId}/lists/${listId}/items`)
|
||||
export async function listItems(
|
||||
houseId: number,
|
||||
listId: number,
|
||||
sortBy?: string,
|
||||
): Promise<ChecklistItem[]> {
|
||||
const resp = await ocs.get<ChecklistItem[]>(`/houses/${houseId}/lists/${listId}/items`, {
|
||||
params: sortBy ? { sortBy } : undefined,
|
||||
})
|
||||
return resp.data ?? []
|
||||
}
|
||||
|
||||
@@ -90,6 +96,14 @@ export async function deleteItem(houseId: number, listId: number, itemId: number
|
||||
await ocs.delete(`/houses/${houseId}/lists/${listId}/items/${itemId}`)
|
||||
}
|
||||
|
||||
export async function reorderItems(
|
||||
houseId: number,
|
||||
listId: number,
|
||||
items: { id: number; sortOrder: number }[],
|
||||
): Promise<void> {
|
||||
await ocs.post(`/houses/${houseId}/lists/${listId}/items/reorder`, { items })
|
||||
}
|
||||
|
||||
export async function uploadItemImage(
|
||||
houseId: number,
|
||||
listId: number,
|
||||
|
||||
@@ -52,6 +52,26 @@ export async function setNoteSort(houseId: number, sort: NoteSort): Promise<{ so
|
||||
return resp.data ?? { sort }
|
||||
}
|
||||
|
||||
export type ChecklistItemSort = 'custom' | 'newest' | 'oldest' | 'name_asc' | 'name_desc'
|
||||
|
||||
export async function getChecklistItemSort(houseId: number): Promise<{ sort: ChecklistItemSort }> {
|
||||
const resp = await ocs.get<{ sort: ChecklistItemSort }>(
|
||||
`/houses/${houseId}/prefs/checklist-item-sort`,
|
||||
)
|
||||
return resp.data ?? { sort: 'custom' }
|
||||
}
|
||||
|
||||
export async function setChecklistItemSort(
|
||||
houseId: number,
|
||||
sort: ChecklistItemSort,
|
||||
): Promise<{ sort: ChecklistItemSort }> {
|
||||
const resp = await ocs.put<{ sort: ChecklistItemSort }>(
|
||||
`/houses/${houseId}/prefs/checklist-item-sort`,
|
||||
{ sort },
|
||||
)
|
||||
return resp.data ?? { sort }
|
||||
}
|
||||
|
||||
export interface NotificationPrefs {
|
||||
notifyPhoto: boolean
|
||||
notifyNoteCreate: boolean
|
||||
|
||||
@@ -214,4 +214,74 @@ describe('ChecklistItemRow', () => {
|
||||
expect(wrapper.emitted('preview')![0]).toEqual([item])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reorderEnabled', () => {
|
||||
it('is not draggable by default', () => {
|
||||
const wrapper = mount(ChecklistItemRow, { props: defaultProps })
|
||||
expect(wrapper.find('.checklist-row').attributes('draggable')).toBe('false')
|
||||
})
|
||||
|
||||
it('is draggable when reorderEnabled is true', () => {
|
||||
const wrapper = mount(ChecklistItemRow, {
|
||||
props: { ...defaultProps, reorderEnabled: true },
|
||||
})
|
||||
expect(wrapper.find('.checklist-row').attributes('draggable')).toBe('true')
|
||||
})
|
||||
|
||||
it('emits drag-start on dragstart when reorderEnabled', async () => {
|
||||
const wrapper = mount(ChecklistItemRow, {
|
||||
props: { ...defaultProps, item: makeItem({ id: 7 }), reorderEnabled: true },
|
||||
})
|
||||
await wrapper.find('.checklist-row').trigger('dragstart', {
|
||||
dataTransfer: { effectAllowed: '', setData: vi.fn() },
|
||||
})
|
||||
expect(wrapper.emitted('drag-start')).toBeTruthy()
|
||||
expect(wrapper.emitted('drag-start')![0]).toEqual([7])
|
||||
})
|
||||
|
||||
it('does not emit drag-start when reorderEnabled is false', async () => {
|
||||
const wrapper = mount(ChecklistItemRow, {
|
||||
props: { ...defaultProps, item: makeItem({ id: 7 }), reorderEnabled: false },
|
||||
})
|
||||
await wrapper.find('.checklist-row').trigger('dragstart', {
|
||||
dataTransfer: { effectAllowed: '', setData: vi.fn() },
|
||||
})
|
||||
expect(wrapper.emitted('drag-start')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('emits reorder-over on dragover when reorderEnabled', async () => {
|
||||
const wrapper = mount(ChecklistItemRow, {
|
||||
props: { ...defaultProps, item: makeItem({ id: 2 }), reorderEnabled: true },
|
||||
})
|
||||
await wrapper.find('.checklist-row').trigger('dragover', {
|
||||
dataTransfer: { types: ['application/x-pantry-checklist-item'] },
|
||||
})
|
||||
expect(wrapper.emitted('reorder-over')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not emit reorder-over when reorderEnabled is false', async () => {
|
||||
const wrapper = mount(ChecklistItemRow, {
|
||||
props: { ...defaultProps, reorderEnabled: false },
|
||||
})
|
||||
await wrapper.find('.checklist-row').trigger('dragover', {
|
||||
dataTransfer: { types: ['application/x-pantry-checklist-item'] },
|
||||
})
|
||||
expect(wrapper.emitted('reorder-over')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('applies dragging class on dragstart and removes on dragend', async () => {
|
||||
const wrapper = mount(ChecklistItemRow, {
|
||||
props: { ...defaultProps, reorderEnabled: true },
|
||||
})
|
||||
const row = wrapper.find('.checklist-row')
|
||||
|
||||
await row.trigger('dragstart', {
|
||||
dataTransfer: { effectAllowed: '', setData: vi.fn() },
|
||||
})
|
||||
expect(row.classes()).toContain('checklist-row--dragging')
|
||||
|
||||
await row.trigger('dragend')
|
||||
expect(row.classes()).not.toContain('checklist-row--dragging')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<template>
|
||||
<li class="checklist-row" :class="{ 'checklist-row--done': item.done }">
|
||||
<li
|
||||
class="checklist-row"
|
||||
:class="{ 'checklist-row--done': item.done, 'checklist-row--dragging': isDragging }"
|
||||
:data-drag-id="item.id"
|
||||
:draggable="reorderEnabled ? 'true' : 'false'"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@dragover.prevent="onDragOver"
|
||||
>
|
||||
<NcCheckboxRadioSwitch :model-value="item.done" @update:model-value="$emit('toggle', item.id)">
|
||||
<span class="checklist-row__label">
|
||||
<button
|
||||
@@ -50,7 +58,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||
@@ -65,20 +73,46 @@ import { itemImagePreviewUrl } from '@/api/images'
|
||||
import { formatRrule } from '@/utils/rrule'
|
||||
import type { ChecklistItem, Category } from '@/api/types'
|
||||
|
||||
const props = defineProps<{
|
||||
item: ChecklistItem
|
||||
category: Category | null
|
||||
houseId: number
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
item: ChecklistItem
|
||||
category: Category | null
|
||||
houseId: number
|
||||
reorderEnabled?: boolean
|
||||
}>(),
|
||||
{ reorderEnabled: false },
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
toggle: [id: number]
|
||||
view: [item: ChecklistItem]
|
||||
edit: [item: ChecklistItem]
|
||||
remove: [id: number]
|
||||
preview: [item: ChecklistItem]
|
||||
'drag-start': [itemId: number]
|
||||
'reorder-over': [itemId: number, event: MouseEvent]
|
||||
}>()
|
||||
|
||||
const isDragging = ref(false)
|
||||
|
||||
function onDragStart(e: DragEvent) {
|
||||
if (!props.reorderEnabled || !e.dataTransfer) return
|
||||
isDragging.value = true
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('application/x-pantry-checklist-item', String(props.item.id))
|
||||
emit('drag-start', props.item.id)
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent) {
|
||||
if (!props.reorderEnabled) return
|
||||
if (!e.dataTransfer?.types.includes('application/x-pantry-checklist-item')) return
|
||||
emit('reorder-over', props.item.id, e)
|
||||
}
|
||||
|
||||
const thumbUrl = computed(() =>
|
||||
props.item.imageFileId
|
||||
? itemImagePreviewUrl(props.houseId, props.item.imageFileId!, props.item.imageUploadedBy!, 64)
|
||||
@@ -133,6 +167,20 @@ const strings = {
|
||||
}
|
||||
}
|
||||
|
||||
&--dragging {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.98);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&[draggable='true'] {
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.checkbox-content__icon) {
|
||||
margin-block: auto !important;
|
||||
}
|
||||
|
||||
282
src/composables/useChecklist.test.ts
Normal file
282
src/composables/useChecklist.test.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import type { Checklist, ChecklistItem } from '@/api/types'
|
||||
|
||||
const mockApi = vi.hoisted(() => ({
|
||||
listLists: vi.fn(),
|
||||
createList: vi.fn(),
|
||||
updateList: vi.fn(),
|
||||
deleteList: vi.fn(),
|
||||
getList: vi.fn(),
|
||||
listItems: vi.fn(),
|
||||
addItem: vi.fn(),
|
||||
updateItem: vi.fn(),
|
||||
toggleItem: vi.fn(),
|
||||
deleteItem: vi.fn(),
|
||||
reorderItems: vi.fn(),
|
||||
uploadItemImage: vi.fn(),
|
||||
clearItemImage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/lists', () => mockApi)
|
||||
|
||||
import { useChecklists, useChecklistItems } from './useChecklist'
|
||||
|
||||
function makeList(overrides: Partial<Checklist> = {}): Checklist {
|
||||
return {
|
||||
id: 1,
|
||||
houseId: 1,
|
||||
name: 'Groceries',
|
||||
description: null,
|
||||
icon: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeItem(overrides: Partial<ChecklistItem> = {}): ChecklistItem {
|
||||
return {
|
||||
id: 1,
|
||||
listId: 10,
|
||||
name: 'Milk',
|
||||
description: null,
|
||||
categoryId: null,
|
||||
quantity: null,
|
||||
done: false,
|
||||
doneAt: null,
|
||||
doneBy: null,
|
||||
rrule: null,
|
||||
repeatFromCompletion: false,
|
||||
nextDueAt: null,
|
||||
imageFileId: null,
|
||||
imageUploadedBy: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useChecklists', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('load', () => {
|
||||
it('loads lists', async () => {
|
||||
const lists = [makeList({ id: 1 }), makeList({ id: 2 })]
|
||||
mockApi.listLists.mockResolvedValue(lists)
|
||||
|
||||
const c = useChecklists(1)
|
||||
await c.load()
|
||||
|
||||
expect(c.lists.value).toEqual(lists)
|
||||
expect(c.loading.value).toBe(false)
|
||||
expect(c.error.value).toBeNull()
|
||||
})
|
||||
|
||||
it('sets error on failure', async () => {
|
||||
mockApi.listLists.mockRejectedValue(new Error('fail'))
|
||||
|
||||
const c = useChecklists(1)
|
||||
await c.load()
|
||||
|
||||
expect(c.error.value).toBe('fail')
|
||||
})
|
||||
})
|
||||
|
||||
describe('create', () => {
|
||||
it('creates and appends to list', async () => {
|
||||
mockApi.listLists.mockResolvedValue([])
|
||||
const newList = makeList({ id: 10 })
|
||||
mockApi.createList.mockResolvedValue(newList)
|
||||
|
||||
const c = useChecklists(1)
|
||||
await c.load()
|
||||
const result = await c.create('New', 'desc', 'cart')
|
||||
|
||||
expect(mockApi.createList).toHaveBeenCalledWith(1, 'New', 'desc', 'cart')
|
||||
expect(result).toEqual(newList)
|
||||
expect(c.lists.value).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('remove', () => {
|
||||
it('removes list from state', async () => {
|
||||
mockApi.listLists.mockResolvedValue([makeList({ id: 1 }), makeList({ id: 2 })])
|
||||
mockApi.deleteList.mockResolvedValue(undefined)
|
||||
|
||||
const c = useChecklists(1)
|
||||
await c.load()
|
||||
await c.remove(1)
|
||||
|
||||
expect(c.lists.value).toHaveLength(1)
|
||||
expect(c.lists.value[0].id).toBe(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useChecklistItems', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('load', () => {
|
||||
it('loads items', async () => {
|
||||
const items = [makeItem({ id: 1 }), makeItem({ id: 2 })]
|
||||
mockApi.listItems.mockResolvedValue(items)
|
||||
|
||||
const c = useChecklistItems(1, 10)
|
||||
await c.load()
|
||||
|
||||
expect(c.items.value).toEqual(items)
|
||||
expect(c.loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('sets error on failure', async () => {
|
||||
mockApi.listItems.mockRejectedValue(new Error('fail'))
|
||||
|
||||
const c = useChecklistItems(1, 10)
|
||||
await c.load()
|
||||
|
||||
expect(c.error.value).toBe('fail')
|
||||
})
|
||||
})
|
||||
|
||||
describe('add', () => {
|
||||
it('adds and appends to items', async () => {
|
||||
mockApi.listItems.mockResolvedValue([])
|
||||
const newItem = makeItem({ id: 10 })
|
||||
mockApi.addItem.mockResolvedValue(newItem)
|
||||
|
||||
const c = useChecklistItems(1, 10)
|
||||
await c.load()
|
||||
const result = await c.add({ name: 'Eggs' })
|
||||
|
||||
expect(mockApi.addItem).toHaveBeenCalledWith(1, 10, { name: 'Eggs' })
|
||||
expect(result).toEqual(newItem)
|
||||
expect(c.items.value).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('update', () => {
|
||||
it('updates item in list', async () => {
|
||||
const original = makeItem({ id: 1, name: 'Old' })
|
||||
const updated = makeItem({ id: 1, name: 'New' })
|
||||
mockApi.listItems.mockResolvedValue([original])
|
||||
mockApi.updateItem.mockResolvedValue(updated)
|
||||
|
||||
const c = useChecklistItems(1, 10)
|
||||
await c.load()
|
||||
await c.update(1, { name: 'New' })
|
||||
|
||||
expect(c.items.value[0].name).toBe('New')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggle', () => {
|
||||
it('optimistically flips done then updates from server', async () => {
|
||||
const item = makeItem({ id: 1, done: false })
|
||||
const toggled = makeItem({ id: 1, done: true, doneAt: 1000, doneBy: 'admin' })
|
||||
mockApi.listItems.mockResolvedValue([item])
|
||||
mockApi.toggleItem.mockResolvedValue(toggled)
|
||||
|
||||
const c = useChecklistItems(1, 10)
|
||||
await c.load()
|
||||
|
||||
// During toggle, done should flip optimistically
|
||||
const togglePromise = c.toggle(1)
|
||||
expect(c.items.value[0].done).toBe(true)
|
||||
|
||||
await togglePromise
|
||||
expect(c.items.value[0].doneBy).toBe('admin')
|
||||
})
|
||||
|
||||
it('rolls back on failure', async () => {
|
||||
const item = makeItem({ id: 1, done: false })
|
||||
mockApi.listItems.mockResolvedValue([item])
|
||||
mockApi.toggleItem.mockRejectedValue(new Error('fail'))
|
||||
|
||||
const c = useChecklistItems(1, 10)
|
||||
await c.load()
|
||||
|
||||
await expect(c.toggle(1)).rejects.toThrow('fail')
|
||||
expect(c.items.value[0].done).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('remove', () => {
|
||||
it('removes item from list', async () => {
|
||||
mockApi.listItems.mockResolvedValue([makeItem({ id: 1 }), makeItem({ id: 2 })])
|
||||
mockApi.deleteItem.mockResolvedValue(undefined)
|
||||
|
||||
const c = useChecklistItems(1, 10)
|
||||
await c.load()
|
||||
await c.remove(1)
|
||||
|
||||
expect(c.items.value).toHaveLength(1)
|
||||
expect(c.items.value[0].id).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reorderItems', () => {
|
||||
it('updates sort orders locally and sorts', async () => {
|
||||
mockApi.listItems.mockResolvedValue([
|
||||
makeItem({ id: 1, sortOrder: 0 }),
|
||||
makeItem({ id: 2, sortOrder: 1 }),
|
||||
])
|
||||
mockApi.reorderItems.mockResolvedValue(undefined)
|
||||
|
||||
const c = useChecklistItems(1, 10)
|
||||
await c.load()
|
||||
await c.reorderItems([
|
||||
{ id: 2, sortOrder: 0 },
|
||||
{ id: 1, sortOrder: 1 },
|
||||
])
|
||||
|
||||
expect(c.items.value[0].id).toBe(2)
|
||||
expect(c.items.value[1].id).toBe(1)
|
||||
expect(mockApi.reorderItems).toHaveBeenCalledWith(1, 10, [
|
||||
{ id: 2, sortOrder: 0 },
|
||||
{ id: 1, sortOrder: 1 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortBy', () => {
|
||||
it('defaults to custom', () => {
|
||||
const c = useChecklistItems(1, 10)
|
||||
expect(c.sortBy.value).toBe('custom')
|
||||
})
|
||||
|
||||
it('passes sortBy value to listItems', async () => {
|
||||
mockApi.listItems.mockResolvedValue([])
|
||||
|
||||
const c = useChecklistItems(1, 10)
|
||||
c.sortBy.value = 'newest'
|
||||
await c.load()
|
||||
|
||||
expect(mockApi.listItems).toHaveBeenCalledWith(1, 10, 'newest')
|
||||
})
|
||||
|
||||
it('uses sort argument when provided to load()', async () => {
|
||||
mockApi.listItems.mockResolvedValue([])
|
||||
|
||||
const c = useChecklistItems(1, 10)
|
||||
c.sortBy.value = 'custom'
|
||||
await c.load('name_asc')
|
||||
|
||||
expect(mockApi.listItems).toHaveBeenCalledWith(1, 10, 'name_asc')
|
||||
})
|
||||
|
||||
it('uses default custom sort when no argument given', async () => {
|
||||
mockApi.listItems.mockResolvedValue([])
|
||||
|
||||
const c = useChecklistItems(1, 10)
|
||||
await c.load()
|
||||
|
||||
expect(mockApi.listItems).toHaveBeenCalledWith(1, 10, 'custom')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref } from 'vue'
|
||||
import * as api from '@/api/lists'
|
||||
import type { Checklist, ChecklistItem } from '@/api/types'
|
||||
import type { ChecklistItemSort } from '@/api/prefs'
|
||||
|
||||
export function useChecklists(houseId: number) {
|
||||
const lists = ref<Checklist[]>([])
|
||||
@@ -49,12 +50,14 @@ export function useChecklistItems(houseId: number, listId: number) {
|
||||
const items = ref<ChecklistItem[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const sortBy = ref<ChecklistItemSort>('custom')
|
||||
|
||||
async function load(): Promise<void> {
|
||||
async function load(sort?: ChecklistItemSort): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const s = sort ?? sortBy.value
|
||||
try {
|
||||
items.value = await api.listItems(houseId, listId)
|
||||
items.value = await api.listItems(houseId, listId, s)
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message
|
||||
} finally {
|
||||
@@ -91,6 +94,14 @@ export function useChecklistItems(houseId: number, listId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function reorderItems(reorderEntries: { id: number; sortOrder: number }[]): Promise<void> {
|
||||
const map = new Map(reorderEntries.map((i) => [i.id, i.sortOrder]))
|
||||
items.value = items.value
|
||||
.map((i) => (map.has(i.id) ? { ...i, sortOrder: map.get(i.id)! } : i))
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
await api.reorderItems(houseId, listId, reorderEntries)
|
||||
}
|
||||
|
||||
async function remove(itemId: number): Promise<void> {
|
||||
await api.deleteItem(houseId, listId, itemId)
|
||||
items.value = items.value.filter((i) => i.id !== itemId)
|
||||
@@ -106,5 +117,18 @@ export function useChecklistItems(houseId: number, listId: number) {
|
||||
items.value = items.value.map((i) => (i.id === itemId ? updated : i))
|
||||
}
|
||||
|
||||
return { items, loading, error, load, add, update, toggle, remove, uploadImage, clearImage }
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
sortBy,
|
||||
load,
|
||||
add,
|
||||
update,
|
||||
toggle,
|
||||
reorderItems,
|
||||
remove,
|
||||
uploadImage,
|
||||
clearImage,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,25 @@
|
||||
</template>
|
||||
</NcButton>
|
||||
</template>
|
||||
<template #actions>
|
||||
<NcActions :aria-label="strings.sortLabel" type="tertiary">
|
||||
<template #icon>
|
||||
<SortIcon :size="20" />
|
||||
</template>
|
||||
<NcActionButton
|
||||
v-for="opt in itemSortOptions"
|
||||
:key="opt.value"
|
||||
:class="{ 'pantry-sort-active': currentSort === opt.value }"
|
||||
@click="changeSort(opt.value)"
|
||||
>
|
||||
<template #icon>
|
||||
<RadioboxMarkedIcon v-if="currentSort === opt.value" :size="20" />
|
||||
<RadioboxBlankIcon v-else :size="20" />
|
||||
</template>
|
||||
{{ opt.label }}
|
||||
</NcActionButton>
|
||||
</NcActions>
|
||||
</template>
|
||||
</PageToolbar>
|
||||
|
||||
<div class="pantry-detail__body">
|
||||
@@ -31,20 +50,49 @@
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<ul v-else class="pantry-detail__items">
|
||||
<ChecklistItemRow
|
||||
v-for="item in sortedItems"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
:category="categoryFor(item.categoryId)"
|
||||
:house-id="houseIdNum"
|
||||
@toggle="handleToggle"
|
||||
@view="openView"
|
||||
@edit="startEdit"
|
||||
@remove="handleRemove"
|
||||
@preview="openPreview"
|
||||
/>
|
||||
</ul>
|
||||
<template v-else>
|
||||
<ul v-if="uncheckedItems.length > 0" ref="uncheckedListRef" class="pantry-detail__items">
|
||||
<template v-for="gi in uncheckedGridItems" :key="gi.key">
|
||||
<li
|
||||
v-if="gi.type === 'placeholder'"
|
||||
class="pantry-detail__placeholder"
|
||||
@dragover.prevent
|
||||
@drop.prevent.stop="onPlaceholderDrop"
|
||||
/>
|
||||
<ChecklistItemRow
|
||||
v-else
|
||||
:item="gi.item"
|
||||
:category="categoryFor(gi.item.categoryId)"
|
||||
:house-id="houseIdNum"
|
||||
:reorder-enabled="isCustomSort"
|
||||
@toggle="handleToggle"
|
||||
@view="openView"
|
||||
@edit="startEdit"
|
||||
@remove="handleRemove"
|
||||
@preview="openPreview"
|
||||
@drag-start="onItemDragStart"
|
||||
@reorder-over="onReorderOver"
|
||||
/>
|
||||
</template>
|
||||
</ul>
|
||||
<template v-if="checkedItems.length > 0">
|
||||
<h3 class="pantry-detail__section-title">{{ strings.doneTitle }}</h3>
|
||||
<ul class="pantry-detail__items pantry-detail__items--done">
|
||||
<ChecklistItemRow
|
||||
v-for="item in checkedItems"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
:category="categoryFor(item.categoryId)"
|
||||
:house-id="houseIdNum"
|
||||
@toggle="handleToggle"
|
||||
@view="openView"
|
||||
@edit="startEdit"
|
||||
@remove="handleRemove"
|
||||
@preview="openPreview"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<ChecklistItemEditDialog
|
||||
@@ -79,12 +127,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
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 NcActions from '@nextcloud/vue/components/NcActions'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
|
||||
import SortIcon from '@icons/Sort.vue'
|
||||
import RadioboxBlankIcon from '@icons/RadioboxBlank.vue'
|
||||
import RadioboxMarkedIcon from '@icons/RadioboxMarked.vue'
|
||||
import PageToolbar from '@/components/PageToolbar'
|
||||
import { ChecklistAddForm } from '@/components/ChecklistAddForm'
|
||||
import { ChecklistItemRow } from '@/components/ChecklistItemRow'
|
||||
@@ -94,9 +147,12 @@ import { ChecklistImagePreview } from '@/components/ChecklistImagePreview'
|
||||
import { checklistIconComponent } from '@/components/ChecklistIconPicker'
|
||||
import { useChecklistItems } from '@/composables/useChecklist'
|
||||
import { useCategories } from '@/composables/useCategories'
|
||||
import { useTouchReorder } from '@/composables/useTouchReorder'
|
||||
import { getList } from '@/api/lists'
|
||||
import type { ItemInput } from '@/api/lists'
|
||||
import type { Checklist, ChecklistItem } from '@/api/types'
|
||||
import type { ChecklistItemSort } from '@/api/prefs'
|
||||
import { getChecklistItemSort, setChecklistItemSort } from '@/api/prefs'
|
||||
|
||||
const props = defineProps<{ houseId: string; listId: string }>()
|
||||
|
||||
@@ -104,14 +160,50 @@ const houseIdNum = computed(() => Number(props.houseId))
|
||||
const listIdNum = computed(() => Number(props.listId))
|
||||
|
||||
const list = ref<Checklist | null>(null)
|
||||
const { items, loading, load, add, update, toggle, remove, uploadImage, clearImage } =
|
||||
useChecklistItems(houseIdNum.value, listIdNum.value)
|
||||
const {
|
||||
items,
|
||||
loading,
|
||||
load,
|
||||
add,
|
||||
update,
|
||||
toggle,
|
||||
reorderItems,
|
||||
remove,
|
||||
uploadImage,
|
||||
clearImage,
|
||||
sortBy,
|
||||
} = useChecklistItems(houseIdNum.value, listIdNum.value)
|
||||
const categories = useCategories(houseIdNum.value)
|
||||
|
||||
function categoryFor(id: number | null) {
|
||||
return categories.findById(id) ?? null
|
||||
}
|
||||
|
||||
// ----- Sort -----
|
||||
|
||||
const currentSort = ref<ChecklistItemSort>('custom')
|
||||
|
||||
const itemSortOptions: { value: ChecklistItemSort; label: string }[] = [
|
||||
{ value: 'newest', label: t('pantry', 'Newest first') },
|
||||
{ value: 'oldest', label: t('pantry', 'Oldest first') },
|
||||
{ value: 'name_asc', label: t('pantry', 'Name A\u2013Z') },
|
||||
{ value: 'name_desc', label: t('pantry', 'Name Z\u2013A') },
|
||||
{ value: 'custom', label: t('pantry', 'Custom') },
|
||||
]
|
||||
|
||||
async function loadSortPref() {
|
||||
const prefs = await getChecklistItemSort(houseIdNum.value)
|
||||
currentSort.value = prefs.sort
|
||||
sortBy.value = prefs.sort
|
||||
}
|
||||
|
||||
async function changeSort(value: ChecklistItemSort) {
|
||||
currentSort.value = value
|
||||
sortBy.value = value
|
||||
await setChecklistItemSort(houseIdNum.value, value)
|
||||
await load(value)
|
||||
}
|
||||
|
||||
// ----- Loading -----
|
||||
|
||||
async function loadList() {
|
||||
@@ -119,26 +211,144 @@ async function loadList() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSortPref()
|
||||
await Promise.all([loadList(), load(), categories.load()])
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.houseId, props.listId],
|
||||
async () => {
|
||||
await loadSortPref()
|
||||
await Promise.all([loadList(), load()])
|
||||
},
|
||||
)
|
||||
|
||||
// ----- Sorting -----
|
||||
// ----- Partitioned items -----
|
||||
|
||||
const sortedItems = computed(() => {
|
||||
return [...items.value].sort((a, b) => {
|
||||
if (a.done !== b.done) return a.done ? 1 : -1
|
||||
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
function sortWithinPartition(arr: ChecklistItem[]): ChecklistItem[] {
|
||||
if (currentSort.value === 'custom') {
|
||||
return [...arr].sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
const isCustomSort = computed(() => currentSort.value === 'custom')
|
||||
const uncheckedItems = computed(() => sortWithinPartition(items.value.filter((i) => !i.done)))
|
||||
const checkedItems = computed(() => sortWithinPartition(items.value.filter((i) => i.done)))
|
||||
|
||||
// ----- Drag/drop reorder (unchecked partition only, custom sort) -----
|
||||
|
||||
type ListGridItem =
|
||||
| { type: 'item'; key: string; item: ChecklistItem }
|
||||
| { type: 'placeholder'; key: string }
|
||||
|
||||
const draggingItemId = ref<number | null>(null)
|
||||
const dropIndex = ref<number | null>(null)
|
||||
const uncheckedListRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const uncheckedGridItems = computed<ListGridItem[]>(() => {
|
||||
const source = uncheckedItems.value
|
||||
const dragId = draggingItemId.value
|
||||
if (!isCustomSort.value || dragId === null || dropIndex.value === null) {
|
||||
return source.map((i) => ({ type: 'item' as const, key: 'i-' + i.id, item: i }))
|
||||
}
|
||||
const without = source.filter((i) => i.id !== dragId)
|
||||
const items: ListGridItem[] = without.map((i) => ({
|
||||
type: 'item' as const,
|
||||
key: 'i-' + i.id,
|
||||
item: i,
|
||||
}))
|
||||
const clamped = Math.min(dropIndex.value, items.length)
|
||||
items.splice(clamped, 0, { type: 'placeholder', key: 'drop-placeholder' })
|
||||
return items
|
||||
})
|
||||
|
||||
function onItemDragStart(itemId: number) {
|
||||
draggingItemId.value = itemId
|
||||
dropIndex.value = null
|
||||
}
|
||||
|
||||
function computeItemDropIndex(hoveredItemId: number, clientY: number, target: HTMLElement | null) {
|
||||
const dragId = draggingItemId.value
|
||||
if (!dragId || dragId === hoveredItemId) return
|
||||
|
||||
const without = uncheckedItems.value.filter((i) => i.id !== dragId)
|
||||
const idx = without.findIndex((i) => i.id === hoveredItemId)
|
||||
if (idx === -1) return
|
||||
|
||||
if (target) {
|
||||
const rect = target.getBoundingClientRect()
|
||||
const past = clientY > rect.top + rect.height / 2
|
||||
dropIndex.value = past ? idx + 1 : idx
|
||||
} else {
|
||||
dropIndex.value = idx
|
||||
}
|
||||
}
|
||||
|
||||
function onReorderOver(hoveredItemId: number, e: MouseEvent) {
|
||||
computeItemDropIndex(hoveredItemId, e.clientY, e.currentTarget as HTMLElement | null)
|
||||
}
|
||||
|
||||
function onPlaceholderDrop() {
|
||||
commitReorder()
|
||||
}
|
||||
|
||||
async function commitReorder() {
|
||||
const dragId = draggingItemId.value
|
||||
const idx = dropIndex.value
|
||||
draggingItemId.value = null
|
||||
dropIndex.value = null
|
||||
|
||||
if (dragId === null || idx === null) return
|
||||
|
||||
const source = uncheckedItems.value
|
||||
const dragged = source.find((i) => i.id === dragId)
|
||||
if (!dragged) return
|
||||
|
||||
const without = source.filter((i) => i.id !== dragId)
|
||||
const clamped = Math.min(idx, without.length)
|
||||
const reordered = [...without]
|
||||
reordered.splice(clamped, 0, dragged)
|
||||
|
||||
const entries = reordered.map((i, idx) => ({ id: i.id, sortOrder: idx }))
|
||||
await reorderItems(entries)
|
||||
}
|
||||
|
||||
// Capture-phase listeners
|
||||
function onDropCapture() {
|
||||
commitReorder()
|
||||
}
|
||||
function onDragEndCapture() {
|
||||
draggingItemId.value = null
|
||||
dropIndex.value = null
|
||||
}
|
||||
onMounted(() => {
|
||||
uncheckedListRef.value?.addEventListener('drop', onDropCapture, true)
|
||||
uncheckedListRef.value?.addEventListener('dragend', onDragEndCapture, true)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
uncheckedListRef.value?.removeEventListener('drop', onDropCapture, true)
|
||||
uncheckedListRef.value?.removeEventListener('dragend', onDragEndCapture, true)
|
||||
})
|
||||
|
||||
// Touch reorder
|
||||
useTouchReorder(
|
||||
uncheckedListRef,
|
||||
{
|
||||
onDragStart: onItemDragStart,
|
||||
onReorderOver(hoveredId, _clientX, clientY) {
|
||||
const el = uncheckedListRef.value?.querySelector<HTMLElement>(`[data-drag-id="${hoveredId}"]`)
|
||||
computeItemDropIndex(hoveredId, clientY, el)
|
||||
},
|
||||
onDrop: commitReorder,
|
||||
onCancel() {
|
||||
draggingItemId.value = null
|
||||
dropIndex.value = null
|
||||
},
|
||||
},
|
||||
isCustomSort,
|
||||
)
|
||||
|
||||
// ----- Add -----
|
||||
|
||||
const adding = ref(false)
|
||||
@@ -213,6 +423,8 @@ const strings = {
|
||||
back: t('pantry', 'Back to lists'),
|
||||
emptyTitle: t('pantry', 'No items yet'),
|
||||
emptyBody: t('pantry', 'Add items using the form above.'),
|
||||
sortLabel: t('pantry', 'Sort order'),
|
||||
doneTitle: t('pantry', 'Done'),
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -237,5 +449,27 @@ const strings = {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
min-height: 48px;
|
||||
border: 3px dashed var(--color-primary-element);
|
||||
border-radius: var(--border-radius, 8px);
|
||||
background: rgba(var(--color-primary-element-rgb, 0, 120, 212), 0.08);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
margin: 1.5rem 0 0.5rem;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-maxcontrast);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
}
|
||||
|
||||
.pantry-sort-active {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -80,7 +80,7 @@ class ChecklistServiceTest extends TestCase {
|
||||
&& $i->getNextDueAt() === null;
|
||||
}));
|
||||
|
||||
$result = $this->svc->listItems(1, $now);
|
||||
$result = $this->svc->listItems(1, 'custom', $now);
|
||||
$this->assertCount(2, $result);
|
||||
$this->assertFalse($result[0]->getDone(), 'Due item should be reopened');
|
||||
$this->assertTrue($result[1]->getDone(), 'Fresh item should stay done');
|
||||
|
||||
Reference in New Issue
Block a user