Skip to content

Commit ad7dae5

Browse files
committed
feat: allow adding one-off list items
1 parent dfcb75d commit ad7dae5

18 files changed

Lines changed: 325 additions & 13 deletions

lib/Controller/ChecklistController.php

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ public function indexItems(int $houseId, int $listId, string $sortBy = 'custom',
211211
* @param string|null $quantity Optional quantity string.
212212
* @param string|null $rrule Optional RFC 5545 RRULE for recurrence.
213213
* @param bool $repeatFromCompletion If true, the next occurrence is measured from when the item is marked done; if false, the schedule is anchored at item creation.
214+
* @param bool $deleteOnDone If true, the item is deleted when marked done.
214215
* @param int|null $sortOrder Optional sort order.
215216
*
216217
* @return DataResponse<Http::STATUS_OK, PantryListItem, array{}>
@@ -228,9 +229,10 @@ public function addItem(
228229
?string $quantity = null,
229230
?string $rrule = null,
230231
bool $repeatFromCompletion = false,
232+
bool $deleteOnDone = false,
231233
?int $sortOrder = null,
232234
): DataResponse {
233-
return $this->runAction(function () use ($houseId, $listId, $name, $description, $categoryId, $quantity, $rrule, $repeatFromCompletion, $sortOrder): DataResponse {
235+
return $this->runAction(function () use ($houseId, $listId, $name, $description, $categoryId, $quantity, $rrule, $repeatFromCompletion, $deleteOnDone, $sortOrder): DataResponse {
234236
$this->auth->requireMember($houseId, $this->requireUid());
235237
$list = $this->lists->getList($listId);
236238
$this->assertListInHouse($list->getHouseId(), $houseId);
@@ -244,6 +246,7 @@ public function addItem(
244246
'quantity' => $quantity,
245247
'rrule' => $rrule,
246248
'repeatFromCompletion' => $repeatFromCompletion,
249+
'deleteOnDone' => $deleteOnDone,
247250
'sortOrder' => $sortOrder ?? 0,
248251
]);
249252
$this->notifications->notifyItemAdded($houseId, $this->requireUid(), $item->getName(), $list->getName());
@@ -263,6 +266,7 @@ public function addItem(
263266
* @param string|null $quantity New quantity (empty string clears).
264267
* @param string|null $rrule New RRULE (empty string clears).
265268
* @param bool|null $repeatFromCompletion New recurrence anchor mode.
269+
* @param bool|null $deleteOnDone If true, the item is deleted when marked done.
266270
* @param int|null $imageFileId File id of attached image (0 or negative clears).
267271
* @param int|null $sortOrder New sort order.
268272
* @param int|null $targetListId Move item to a different list (must belong to the same house).
@@ -283,11 +287,12 @@ public function updateItem(
283287
?string $quantity = null,
284288
?string $rrule = null,
285289
?bool $repeatFromCompletion = null,
290+
?bool $deleteOnDone = null,
286291
?int $imageFileId = null,
287292
?int $sortOrder = null,
288293
?int $targetListId = null,
289294
): DataResponse {
290-
return $this->runAction(function () use ($houseId, $listId, $itemId, $name, $description, $categoryId, $quantity, $rrule, $repeatFromCompletion, $imageFileId, $sortOrder, $targetListId): DataResponse {
295+
return $this->runAction(function () use ($houseId, $listId, $itemId, $name, $description, $categoryId, $quantity, $rrule, $repeatFromCompletion, $deleteOnDone, $imageFileId, $sortOrder, $targetListId): DataResponse {
291296
$this->auth->requireMember($houseId, $this->requireUid());
292297
$item = $this->lists->getItem($itemId);
293298
$list = $this->lists->getList($item->getListId());
@@ -319,6 +324,9 @@ public function updateItem(
319324
if ($repeatFromCompletion !== null) {
320325
$patch['repeatFromCompletion'] = $repeatFromCompletion;
321326
}
327+
if ($deleteOnDone !== null) {
328+
$patch['deleteOnDone'] = $deleteOnDone;
329+
}
322330
if ($imageFileId !== null) {
323331
$patch['imageFileId'] = $imageFileId > 0 ? $imageFileId : null;
324332
}

lib/Db/ChecklistItem.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
* @method void setRrule(?string $rrule)
3131
* @method bool getRepeatFromCompletion()
3232
* @method void setRepeatFromCompletion(bool $repeatFromCompletion)
33+
* @method bool getDeleteOnDone()
34+
* @method void setDeleteOnDone(bool $deleteOnDone)
3335
* @method int|null getNextDueAt()
3436
* @method void setNextDueAt(?int $nextDueAt)
3537
* @method int|null getImageFileId()
@@ -54,6 +56,7 @@ class ChecklistItem extends Entity implements \JsonSerializable {
5456
protected ?string $doneBy = null;
5557
protected ?string $rrule = null;
5658
protected bool $repeatFromCompletion = false;
59+
protected bool $deleteOnDone = false;
5760
protected ?int $nextDueAt = null;
5861
protected ?int $imageFileId = null;
5962
protected ?string $imageUploadedBy = null;
@@ -67,6 +70,7 @@ public function __construct() {
6770
$this->addType('done', 'boolean');
6871
$this->addType('doneAt', 'integer');
6972
$this->addType('repeatFromCompletion', 'boolean');
73+
$this->addType('deleteOnDone', 'boolean');
7074
$this->addType('nextDueAt', 'integer');
7175
$this->addType('imageFileId', 'integer');
7276
$this->addType('sortOrder', 'integer');
@@ -78,6 +82,7 @@ public function __construct() {
7882
// fromRow() resets updated fields after hydration, so reads are unaffected.
7983
$this->markFieldUpdated('done');
8084
$this->markFieldUpdated('repeatFromCompletion');
85+
$this->markFieldUpdated('deleteOnDone');
8186
}
8287

8388
public function jsonSerialize(): array {
@@ -93,6 +98,7 @@ public function jsonSerialize(): array {
9398
'doneBy' => $this->doneBy,
9499
'rrule' => $this->rrule,
95100
'repeatFromCompletion' => $this->repeatFromCompletion,
101+
'deleteOnDone' => $this->deleteOnDone,
96102
'nextDueAt' => $this->nextDueAt,
97103
'imageFileId' => $this->imageFileId,
98104
'imageUploadedBy' => $this->imageUploadedBy,
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
6+
// SPDX-License-Identifier: AGPL-3.0-or-later
7+
8+
namespace OCA\Pantry\Migration;
9+
10+
use Closure;
11+
use OCA\Pantry\AppInfo\Application;
12+
use OCP\DB\ISchemaWrapper;
13+
use OCP\DB\Types;
14+
use OCP\Migration\IOutput;
15+
use OCP\Migration\SimpleMigrationStep;
16+
17+
/**
18+
* Add delete_on_done flag to checklist items ("Once" items that are removed
19+
* from the list when marked done).
20+
*/
21+
class Version3Date20260416000000 extends SimpleMigrationStep {
22+
/**
23+
* @param Closure():ISchemaWrapper $schemaClosure
24+
*/
25+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
26+
/** @var ISchemaWrapper $schema */
27+
$schema = $schemaClosure();
28+
29+
$itemsTable = Application::tableName('list_items');
30+
if ($schema->hasTable($itemsTable)) {
31+
$table = $schema->getTable($itemsTable);
32+
if (!$table->hasColumn('delete_on_done')) {
33+
$table->addColumn('delete_on_done', Types::BOOLEAN, [
34+
'notnull' => false,
35+
'default' => false,
36+
]);
37+
}
38+
}
39+
40+
return $schema;
41+
}
42+
}

lib/ResponseDefinitions.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
* doneBy: string|null,
5151
* rrule: string|null,
5252
* repeatFromCompletion: bool,
53+
* deleteOnDone: bool,
5354
* nextDueAt: int|null,
5455
* imageFileId: int|null,
5556
* imageUploadedBy: string|null,

lib/Service/ChecklistService.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ public function addItem(int $listId, array $data): ChecklistItem {
143143
$item->setRrule($rrule);
144144
$repeatFromCompletion = !empty($data['repeatFromCompletion']);
145145
$item->setRepeatFromCompletion($repeatFromCompletion);
146+
$item->setDeleteOnDone(!empty($data['deleteOnDone']));
146147
// For fixed-schedule items, compute the first due time immediately.
147148
if ($rrule !== null && !$repeatFromCompletion) {
148149
$item->setNextDueAt($this->computeNextDueAt($item, $now)?->getTimestamp());
@@ -194,6 +195,9 @@ public function updateItem(int $itemId, array $patch): ChecklistItem {
194195
if (array_key_exists('repeatFromCompletion', $patch)) {
195196
$item->setRepeatFromCompletion((bool)$patch['repeatFromCompletion']);
196197
}
198+
if (array_key_exists('deleteOnDone', $patch)) {
199+
$item->setDeleteOnDone((bool)$patch['deleteOnDone']);
200+
}
197201
if (array_key_exists('imageFileId', $patch)) {
198202
$item->setImageFileId($this->intOrNull($patch['imageFileId']));
199203
}
@@ -256,6 +260,13 @@ public function toggleItem(int $itemId, string $uid, ?int $now = null): Checklis
256260
$item->setDone(true);
257261
$item->setDoneAt($now);
258262
$item->setDoneBy($uid);
263+
// "Once" items are removed from the list when marked done. We still
264+
// return the transient done state so callers (notifications, API
265+
// response) can reflect the completion before the row is gone.
266+
if ($item->getDeleteOnDone()) {
267+
$this->itemMapper->delete($item);
268+
return $item;
269+
}
259270
if ($item->getRrule() !== null) {
260271
$item->setNextDueAt($this->computeNextDueAt($item, $now)?->getTimestamp());
261272
}

openapi.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@
295295
"doneBy",
296296
"rrule",
297297
"repeatFromCompletion",
298+
"deleteOnDone",
298299
"nextDueAt",
299300
"imageFileId",
300301
"imageUploadedBy",
@@ -346,6 +347,9 @@
346347
"repeatFromCompletion": {
347348
"type": "boolean"
348349
},
350+
"deleteOnDone": {
351+
"type": "boolean"
352+
},
349353
"nextDueAt": {
350354
"type": "integer",
351355
"format": "int64",
@@ -1950,6 +1954,11 @@
19501954
"default": false,
19511955
"description": "If true, the next occurrence is measured from when the item is marked done; if false, the schedule is anchored at item creation."
19521956
},
1957+
"deleteOnDone": {
1958+
"type": "boolean",
1959+
"default": false,
1960+
"description": "If true, the item is deleted when marked done."
1961+
},
19531962
"sortOrder": {
19541963
"type": "integer",
19551964
"format": "int64",
@@ -2115,6 +2124,12 @@
21152124
"default": null,
21162125
"description": "New recurrence anchor mode."
21172126
},
2127+
"deleteOnDone": {
2128+
"type": "boolean",
2129+
"nullable": true,
2130+
"default": null,
2131+
"description": "If true, the item is deleted when marked done."
2132+
},
21182133
"imageFileId": {
21192134
"type": "integer",
21202135
"format": "int64",

src/api/lists.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export interface ItemInput {
5656
quantity?: string | null
5757
rrule?: string | null
5858
repeatFromCompletion?: boolean
59+
deleteOnDone?: boolean
5960
sortOrder?: number
6061
targetListId?: number
6162
}

src/api/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export interface ChecklistItem {
5353
doneBy: string | null
5454
rrule: string | null
5555
repeatFromCompletion: boolean
56+
deleteOnDone: boolean
5657
nextDueAt: number | null
5758
imageFileId: number | null
5859
imageUploadedBy: string | null

src/components/ChecklistAddForm/ChecklistAddForm.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ vi.mock('@nextcloud/vue/components/NcTextField', () => ({
2525
emits: ['update:modelValue'],
2626
},
2727
}))
28+
vi.mock('@nextcloud/vue/components/NcCheckboxRadioSwitch', () => ({
29+
default: {
30+
name: 'NcCheckboxRadioSwitch',
31+
template:
32+
'<label class="nc-checkbox"><input type="checkbox" :checked="modelValue" @change="$emit(\'update:modelValue\', $event.target.checked)" /><slot /></label>',
33+
props: ['modelValue'],
34+
emits: ['update:modelValue'],
35+
},
36+
}))
2837
vi.mock('@/components/AutoResizeTextarea', () => ({
2938
AutoResizeTextarea: {
3039
name: 'AutoResizeTextarea',
@@ -112,9 +121,35 @@ describe('ChecklistAddForm', () => {
112121
categoryId: null,
113122
rrule: null,
114123
repeatFromCompletion: false,
124+
deleteOnDone: false,
115125
})
116126
})
117127

128+
it('emits deleteOnDone=true when the "Once" checkbox is ticked', async () => {
129+
const wrapper = mountForm()
130+
await wrapper.findAll('.nc-text-field').at(0)!.setValue('Milk')
131+
132+
const onceCheckbox = wrapper.find('.nc-checkbox input[type="checkbox"]')
133+
await onceCheckbox.setValue(true)
134+
135+
await wrapper.find('form').trigger('submit')
136+
137+
const payload = wrapper.emitted('add')![0][0]
138+
expect(payload.deleteOnDone).toBe(true)
139+
})
140+
141+
it('resets the "Once" checkbox after submit', async () => {
142+
const wrapper = mountForm()
143+
await wrapper.findAll('.nc-text-field').at(0)!.setValue('Milk')
144+
const onceCheckbox = wrapper.find('.nc-checkbox input[type="checkbox"]')
145+
await onceCheckbox.setValue(true)
146+
147+
await wrapper.find('form').trigger('submit')
148+
149+
const after = wrapper.find('.nc-checkbox input[type="checkbox"]').element as HTMLInputElement
150+
expect(after.checked).toBe(false)
151+
})
152+
118153
it('resets all fields after submit', async () => {
119154
const wrapper = mountForm()
120155
const textFields = wrapper.findAll('.nc-text-field')

src/components/ChecklistAddForm/ChecklistAddForm.vue

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@
1717
:house-id="houseId"
1818
:placeholder="strings.categoryPlaceholder"
1919
/>
20-
<NcButton variant="tertiary" @click="showRecurrenceEditor = true">
20+
<div class="checklist-add__once" :title="strings.onceHint">
21+
<NcCheckboxRadioSwitch v-model="deleteOnDone">
22+
{{ strings.once }}
23+
</NcCheckboxRadioSwitch>
24+
</div>
25+
<NcButton v-if="!deleteOnDone" variant="tertiary" @click="showRecurrenceEditor = true">
2126
<template #icon>
2227
<RepeatIcon :size="20" />
2328
</template>
@@ -63,6 +68,7 @@
6368
import { ref } from 'vue'
6469
import { t } from '@nextcloud/l10n'
6570
import NcButton from '@nextcloud/vue/components/NcButton'
71+
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
6672
import NcTextField from '@nextcloud/vue/components/NcTextField'
6773
import PlusIcon from '@icons/Plus.vue'
6874
import RepeatIcon from '@icons/Repeat.vue'
@@ -87,26 +93,31 @@ const quantity = ref('')
8793
const categoryId = ref<number | null>(null)
8894
const rrule = ref<string | null>(null)
8995
const repeatFromCompletion = ref(false)
96+
const deleteOnDone = ref(false)
9097
const showDescription = ref(false)
9198
const showRecurrenceEditor = ref(false)
9299
93100
function submitAdd() {
94101
const trimmedName = name.value.trim()
95102
if (!trimmedName) return
103+
// "Once" items can't recur — ignore any locally-retained recurrence settings.
104+
const once = deleteOnDone.value
96105
emit('add', {
97106
name: trimmedName,
98107
description: description.value.trim() || null,
99108
quantity: quantity.value.trim() || null,
100109
categoryId: categoryId.value,
101-
rrule: rrule.value,
102-
repeatFromCompletion: repeatFromCompletion.value,
110+
rrule: once ? null : rrule.value,
111+
repeatFromCompletion: once ? false : repeatFromCompletion.value,
112+
deleteOnDone: once,
103113
})
104114
name.value = ''
105115
description.value = ''
106116
quantity.value = ''
107117
categoryId.value = null
108118
rrule.value = null
109119
repeatFromCompletion.value = false
120+
deleteOnDone.value = false
110121
showDescription.value = false
111122
}
112123
@@ -119,6 +130,8 @@ const strings = {
119130
categoryPlaceholder: t('pantry', 'Category'),
120131
recurrenceButton: t('pantry', 'Repeat …'),
121132
recurrenceSet: t('pantry', 'Repeat: set'),
133+
once: t('pantry', 'Once'),
134+
onceHint: t('pantry', 'Delete this item once it is marked as done.'),
122135
descriptionLabel: t('pantry', 'Description'),
123136
descriptionPlaceholder: t('pantry', 'Add a description …'),
124137
descriptionToggle: t('pantry', 'Toggle description'),
@@ -128,7 +141,7 @@ const strings = {
128141
<style scoped lang="scss">
129142
.checklist-add {
130143
display: grid;
131-
grid-template-columns: 2fr 1fr 1fr auto auto auto;
144+
grid-template-columns: 2fr 1fr 1fr auto auto auto auto;
132145
gap: 0.75rem;
133146
align-items: end;
134147
margin-bottom: 1.5rem;
@@ -145,6 +158,10 @@ const strings = {
145158
grid-column: 1 / -1;
146159
}
147160
161+
&__once {
162+
padding-bottom: 0.25rem;
163+
}
164+
148165
&__chevron {
149166
transition: transform 0.2s ease;
150167

0 commit comments

Comments
 (0)