feat: support checklist item sorting/reordering

This commit is contained in:
2026-04-08 16:55:51 +03:00
parent 86a8bd3567
commit 4384b291e5
14 changed files with 1256 additions and 47 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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