mirror of
https://github.com/chenasraf/nextcloud-pantry.git
synced 2026-05-17 17:28:01 +00:00
feat(checklist): add 'click row to complete' user pref
This commit is contained in:
@@ -66,7 +66,11 @@ final class PrefsController extends OCSController {
|
||||
/**
|
||||
* Update user-level preferences
|
||||
*
|
||||
* Only the fields present in the request body are updated; omitted fields
|
||||
* are left unchanged.
|
||||
*
|
||||
* @param int|null $lastHouseId Last-used house id, or null to clear.
|
||||
* @param bool|null $tapRowToComplete Whether clicking anywhere on a checklist row marks it done.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantryUserPrefs, array{}>
|
||||
*
|
||||
@@ -74,16 +78,16 @@ final class PrefsController extends OCSController {
|
||||
*/
|
||||
#[ApiRoute(verb: 'PUT', url: '/api/prefs')]
|
||||
#[NoAdminRequired]
|
||||
public function setUserPrefs(?int $lastHouseId = null): DataResponse {
|
||||
return $this->runAction(function () use ($lastHouseId): DataResponse {
|
||||
public function setUserPrefs(?int $lastHouseId = null, ?bool $tapRowToComplete = null): DataResponse {
|
||||
return $this->runAction(function () use ($lastHouseId, $tapRowToComplete): DataResponse {
|
||||
$uid = $this->requireUid();
|
||||
$patch = [];
|
||||
if ($lastHouseId !== null) {
|
||||
$this->auth->requireMember($lastHouseId, $uid);
|
||||
$patch['lastHouseId'] = $lastHouseId;
|
||||
} else {
|
||||
// Explicit null means clear
|
||||
$patch['lastHouseId'] = null;
|
||||
}
|
||||
if ($tapRowToComplete !== null) {
|
||||
$patch['tapRowToComplete'] = $tapRowToComplete;
|
||||
}
|
||||
$this->prefs->setUserPrefs($uid, $patch);
|
||||
return new DataResponse($this->prefs->getAllUserPrefs($uid));
|
||||
|
||||
@@ -84,6 +84,7 @@ namespace OCA\Pantry;
|
||||
* @psalm-type PantryUserPrefs = array{
|
||||
* lastHouseId: int|null,
|
||||
* firstDayOfWeek: int,
|
||||
* tapRowToComplete: bool,
|
||||
* }
|
||||
*
|
||||
* @psalm-type PantryHousePrefs = array{
|
||||
|
||||
@@ -14,6 +14,7 @@ use OCP\IL10N;
|
||||
class PrefsService {
|
||||
private const KEY_LAST_HOUSE = 'last_house_id';
|
||||
private const KEY_IMAGE_FOLDER = 'image_folder';
|
||||
private const KEY_TAP_ROW_TO_COMPLETE = 'tap_row_to_complete';
|
||||
public const DEFAULT_IMAGE_FOLDER = '/Pantry';
|
||||
|
||||
public function __construct(
|
||||
@@ -62,6 +63,25 @@ class PrefsService {
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
public function getTapRowToComplete(string $uid): bool {
|
||||
// Off by default — taps only register on the checkbox itself.
|
||||
return $this->config->getUserValue(
|
||||
$uid,
|
||||
Application::APP_ID,
|
||||
self::KEY_TAP_ROW_TO_COMPLETE,
|
||||
'0',
|
||||
) === '1';
|
||||
}
|
||||
|
||||
public function setTapRowToComplete(string $uid, bool $value): void {
|
||||
$this->config->setUserValue(
|
||||
$uid,
|
||||
Application::APP_ID,
|
||||
self::KEY_TAP_ROW_TO_COMPLETE,
|
||||
$value ? '1' : '0',
|
||||
);
|
||||
}
|
||||
|
||||
// ----- Unified user prefs -----
|
||||
|
||||
/**
|
||||
@@ -71,6 +91,7 @@ class PrefsService {
|
||||
return [
|
||||
'lastHouseId' => $this->getLastHouseId($uid),
|
||||
'firstDayOfWeek' => $this->getFirstDayOfWeek($uid),
|
||||
'tapRowToComplete' => $this->getTapRowToComplete($uid),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -82,6 +103,9 @@ class PrefsService {
|
||||
$v = $patch['lastHouseId'];
|
||||
$this->setLastHouseId($uid, is_int($v) ? $v : null);
|
||||
}
|
||||
if (array_key_exists('tapRowToComplete', $patch) && is_bool($patch['tapRowToComplete'])) {
|
||||
$this->setTapRowToComplete($uid, $patch['tapRowToComplete']);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Sort preferences -----
|
||||
|
||||
13
openapi.json
13
openapi.json
@@ -658,7 +658,8 @@
|
||||
"type": "object",
|
||||
"required": [
|
||||
"lastHouseId",
|
||||
"firstDayOfWeek"
|
||||
"firstDayOfWeek",
|
||||
"tapRowToComplete"
|
||||
],
|
||||
"properties": {
|
||||
"lastHouseId": {
|
||||
@@ -669,6 +670,9 @@
|
||||
"firstDayOfWeek": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"tapRowToComplete": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6952,6 +6956,7 @@
|
||||
"put": {
|
||||
"operationId": "prefs-set-user-prefs",
|
||||
"summary": "Update user-level preferences",
|
||||
"description": "Only the fields present in the request body are updated; omitted fields are left unchanged.",
|
||||
"tags": [
|
||||
"prefs"
|
||||
],
|
||||
@@ -6976,6 +6981,12 @@
|
||||
"nullable": true,
|
||||
"default": null,
|
||||
"description": "Last-used house id, or null to clear."
|
||||
},
|
||||
"tapRowToComplete": {
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"default": null,
|
||||
"description": "Whether clicking anywhere on a checklist row marks it done."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@ export interface UserPrefs {
|
||||
lastHouseId: number | null
|
||||
/** 0 = Sunday, 1 = Monday, …, 6 = Saturday. Read-only from server. */
|
||||
firstDayOfWeek: number
|
||||
/** When true, clicking anywhere on a checklist row marks the item done. */
|
||||
tapRowToComplete: boolean
|
||||
}
|
||||
|
||||
const userPrefsDefaults: UserPrefs = {
|
||||
lastHouseId: null,
|
||||
firstDayOfWeek: 1,
|
||||
tapRowToComplete: false,
|
||||
}
|
||||
|
||||
let userPrefsInflight: Promise<UserPrefs> | null = null
|
||||
@@ -15,7 +23,7 @@ export function getUserPrefs(): Promise<UserPrefs> {
|
||||
|
||||
userPrefsInflight = ocs
|
||||
.get<UserPrefs>('/prefs')
|
||||
.then((resp) => resp.data ?? { lastHouseId: null, firstDayOfWeek: 1 })
|
||||
.then((resp) => ({ ...userPrefsDefaults, ...resp.data }))
|
||||
.finally(() => {
|
||||
userPrefsInflight = null
|
||||
})
|
||||
@@ -25,7 +33,7 @@ export function getUserPrefs(): Promise<UserPrefs> {
|
||||
|
||||
export async function setUserPrefs(patch: Partial<UserPrefs>): Promise<UserPrefs> {
|
||||
const resp = await ocs.put<UserPrefs>('/prefs', patch)
|
||||
return resp.data ?? { lastHouseId: null, firstDayOfWeek: 1 }
|
||||
return { ...userPrefsDefaults, ...resp.data }
|
||||
}
|
||||
|
||||
// Convenience wrappers (used widely, keep the simple API)
|
||||
@@ -38,6 +46,16 @@ export async function setLastHouse(houseId: number | null): Promise<void> {
|
||||
await setUserPrefs({ lastHouseId: houseId })
|
||||
}
|
||||
|
||||
export async function getTapRowToComplete(): Promise<boolean> {
|
||||
const prefs = await getUserPrefs()
|
||||
return prefs.tapRowToComplete
|
||||
}
|
||||
|
||||
export async function setTapRowToComplete(value: boolean): Promise<boolean> {
|
||||
const prefs = await setUserPrefs({ tapRowToComplete: value })
|
||||
return prefs.tapRowToComplete
|
||||
}
|
||||
|
||||
// ----- Per-house prefs -----
|
||||
|
||||
export type PhotoSort = 'custom' | 'newest' | 'oldest' | 'description_asc' | 'description_desc'
|
||||
|
||||
@@ -11,6 +11,8 @@ vi.mock('@/api/prefs', () => ({
|
||||
setImageFolder: vi.fn(),
|
||||
getNotificationPrefs: vi.fn(),
|
||||
setNotificationPrefs: vi.fn(),
|
||||
getTapRowToComplete: vi.fn(),
|
||||
setTapRowToComplete: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock Nextcloud Vue components that pull in CSS
|
||||
@@ -24,7 +26,7 @@ vi.mock('@nextcloud/vue/components/NcAppSettingsDialog', () => ({
|
||||
vi.mock('@nextcloud/vue/components/NcAppSettingsSection', () => ({
|
||||
default: {
|
||||
name: 'NcAppSettingsSection',
|
||||
template: '<div class="nc-app-settings-section"><slot /></div>',
|
||||
template: '<div class="nc-app-settings-section" :id="id"><slot /></div>',
|
||||
props: ['id', 'name'],
|
||||
},
|
||||
}))
|
||||
@@ -66,7 +68,7 @@ const NcAppSettingsDialogStub = {
|
||||
}
|
||||
|
||||
const NcAppSettingsSectionStub = {
|
||||
template: '<div class="nc-app-settings-section"><slot /></div>',
|
||||
template: '<div class="nc-app-settings-section" :id="id"><slot /></div>',
|
||||
props: ['id', 'name'],
|
||||
}
|
||||
|
||||
@@ -225,7 +227,8 @@ describe('AccountSettingsDialog', () => {
|
||||
const wrapper = mountComponent({ open: true, houseId: 1 })
|
||||
await flushPromises()
|
||||
|
||||
const checkboxes = wrapper.findAll('.nc-checkbox')
|
||||
const notifSection = wrapper.find('#pantry-notifications')
|
||||
const checkboxes = notifSection.findAll('.nc-checkbox')
|
||||
expect(checkboxes).toHaveLength(6)
|
||||
})
|
||||
|
||||
@@ -242,7 +245,8 @@ describe('AccountSettingsDialog', () => {
|
||||
const wrapper = mountComponent({ open: true, houseId: 3 })
|
||||
await flushPromises()
|
||||
|
||||
const checkbox = wrapper.find('.nc-checkbox input')
|
||||
const notifSection = wrapper.find('#pantry-notifications')
|
||||
const checkbox = notifSection.find('.nc-checkbox input')
|
||||
await checkbox.setValue(false)
|
||||
await flushPromises()
|
||||
|
||||
|
||||
@@ -31,6 +31,20 @@
|
||||
</form>
|
||||
</NcAppSettingsSection>
|
||||
|
||||
<NcAppSettingsSection id="pantry-interface" :name="strings.interfaceSection">
|
||||
<div class="account-settings__checks">
|
||||
<NcCheckboxRadioSwitch
|
||||
:model-value="tapRowToComplete"
|
||||
@update:model-value="updateTapRowToComplete($event)"
|
||||
>
|
||||
{{ strings.tapRowToCompleteLabel }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<p class="account-settings__hint account-settings__hint--inline">
|
||||
{{ strings.tapRowToCompleteHint }}
|
||||
</p>
|
||||
</div>
|
||||
</NcAppSettingsSection>
|
||||
|
||||
<NcAppSettingsSection id="pantry-notifications" :name="strings.notificationsSection">
|
||||
<p class="account-settings__hint">{{ strings.notificationsHint }}</p>
|
||||
<div class="account-settings__checks">
|
||||
@@ -92,6 +106,7 @@ import {
|
||||
setNotificationPrefs,
|
||||
type NotificationPrefs,
|
||||
} from '@/api/prefs'
|
||||
import { useTapRowToComplete } from '@/composables/useTapRowToComplete'
|
||||
|
||||
const props = defineProps<{ open: boolean; houseId: number | null }>()
|
||||
const emit = defineEmits<{ 'update:open': [value: boolean] }>()
|
||||
@@ -187,6 +202,18 @@ async function updateNotifPref(key: keyof NotificationPrefs, value: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Interface prefs -----
|
||||
|
||||
const { tapRowToComplete, set: setTapRowPref } = useTapRowToComplete()
|
||||
|
||||
async function updateTapRowToComplete(value: boolean) {
|
||||
try {
|
||||
await setTapRowPref(value)
|
||||
} catch {
|
||||
// Composable already reverted the optimistic update.
|
||||
}
|
||||
}
|
||||
|
||||
const strings = {
|
||||
title: t('pantry', 'Account settings'),
|
||||
imagesSection: t('pantry', 'Images'),
|
||||
@@ -211,6 +238,12 @@ const strings = {
|
||||
notifyItemAdd: t('pantry', 'Checklist items added'),
|
||||
notifyItemRecur: t('pantry', 'Recurring items reappearing'),
|
||||
notifyItemDone: t('pantry', 'Checklist items completed'),
|
||||
interfaceSection: t('pantry', 'Interface'),
|
||||
tapRowToCompleteLabel: t('pantry', 'Click row to complete items'),
|
||||
tapRowToCompleteHint: t(
|
||||
'pantry',
|
||||
'When off, items are only marked complete by clicking the checkbox.',
|
||||
),
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -219,6 +252,10 @@ const strings = {
|
||||
color: var(--color-text-maxcontrast);
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.9rem;
|
||||
|
||||
&--inline {
|
||||
margin: 0 0 0 1.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.account-settings__form {
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
<template>
|
||||
<li
|
||||
class="checklist-row"
|
||||
:class="{ 'checklist-row--done': item.done, 'checklist-row--dragging': isDragging }"
|
||||
: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">
|
||||
<div class="checklist-row__check">
|
||||
<NcCheckboxRadioSwitch
|
||||
:model-value="item.done"
|
||||
:aria-label="tapRowToComplete ? undefined : item.name"
|
||||
:class="{ 'checklist-row__check-fill': tapRowToComplete }"
|
||||
@update:model-value="$emit('toggle', item.id)"
|
||||
>
|
||||
<span v-if="tapRowToComplete" class="checklist-row__label">
|
||||
<button
|
||||
v-if="item.imageFileId"
|
||||
type="button"
|
||||
class="checklist-row__thumb"
|
||||
:aria-label="strings.viewImage"
|
||||
@click.stop.prevent="$emit('preview', item)"
|
||||
>
|
||||
<img :src="thumbUrl" :alt="item.name" />
|
||||
</button>
|
||||
<span class="checklist-row__name">{{ item.name }}</span>
|
||||
</span>
|
||||
</NcCheckboxRadioSwitch>
|
||||
<span v-if="!tapRowToComplete" class="checklist-row__label checklist-row__label--standalone">
|
||||
<button
|
||||
v-if="item.imageFileId"
|
||||
type="button"
|
||||
@@ -21,7 +43,7 @@
|
||||
</button>
|
||||
<span class="checklist-row__name">{{ item.name }}</span>
|
||||
</span>
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
<div class="checklist-row__meta">
|
||||
<span v-if="item.quantity" class="checklist-row__quantity">× {{ item.quantity }}</span>
|
||||
<span v-if="item.rrule" class="checklist-row__recurrence" :title="recurrenceTooltip">
|
||||
@@ -94,8 +116,9 @@ const props = withDefaults(
|
||||
houseId: number
|
||||
reorderEnabled?: boolean
|
||||
trashMode?: boolean
|
||||
tapRowToComplete?: boolean
|
||||
}>(),
|
||||
{ reorderEnabled: false, trashMode: false },
|
||||
{ reorderEnabled: false, trashMode: false, tapRowToComplete: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -175,7 +198,7 @@ const strings = {
|
||||
'meta meta';
|
||||
gap: 0.25rem 0.5rem;
|
||||
|
||||
:deep(.checkbox-radio-switch) {
|
||||
.checklist-row__check {
|
||||
grid-area: check;
|
||||
}
|
||||
|
||||
@@ -210,19 +233,42 @@ const strings = {
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.checkbox-content__icon) {
|
||||
margin-block: auto !important;
|
||||
&__check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.checkbox-radio-switch__content) {
|
||||
width: 100%;
|
||||
max-width: unset;
|
||||
// When the row-tap pref is on, the label content sits inside the
|
||||
// NcCheckboxRadioSwitch slot. Stretch the checkbox component (and its
|
||||
// inner content wrapper) so the hover highlight and click target span
|
||||
// the whole row.
|
||||
&__check-fill {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
:deep(.checkbox-radio-switch__content) {
|
||||
width: 100%;
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.checkbox-content__icon) {
|
||||
margin-block: auto !important;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
min-width: 0;
|
||||
|
||||
// Standalone label (checkbox-only mode): fills the remaining space
|
||||
// next to the checkbox in the flex container.
|
||||
&--standalone {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__thumb {
|
||||
|
||||
40
src/composables/useTapRowToComplete.ts
Normal file
40
src/composables/useTapRowToComplete.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ref } from 'vue'
|
||||
import { getTapRowToComplete, setTapRowToComplete } from '@/api/prefs'
|
||||
|
||||
// Module-level ref so every consumer reads the same reactive value.
|
||||
// Updating it from the settings dialog instantly reflects in any open
|
||||
// checklist view, no remount needed.
|
||||
const value = ref(false)
|
||||
let loaded = false
|
||||
let inflight: Promise<void> | null = null
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (loaded) return
|
||||
if (inflight) return inflight
|
||||
inflight = (async () => {
|
||||
try {
|
||||
value.value = await getTapRowToComplete()
|
||||
loaded = true
|
||||
} finally {
|
||||
inflight = null
|
||||
}
|
||||
})()
|
||||
return inflight
|
||||
}
|
||||
|
||||
async function set(next: boolean): Promise<void> {
|
||||
const previous = value.value
|
||||
value.value = next
|
||||
try {
|
||||
value.value = await setTapRowToComplete(next)
|
||||
loaded = true
|
||||
} catch (e) {
|
||||
value.value = previous
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export function useTapRowToComplete() {
|
||||
void load()
|
||||
return { tapRowToComplete: value, set }
|
||||
}
|
||||
@@ -102,6 +102,7 @@
|
||||
:house-id="houseIdNum"
|
||||
:reorder-enabled="isCustomSort"
|
||||
:trash-mode="trashMode"
|
||||
:tap-row-to-complete="tapRowToComplete"
|
||||
@toggle="handleToggle"
|
||||
@view="openView"
|
||||
@edit="startEdit"
|
||||
@@ -261,6 +262,7 @@ 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'
|
||||
import { useTapRowToComplete } from '@/composables/useTapRowToComplete'
|
||||
|
||||
const props = defineProps<{ houseId: string; listId: string }>()
|
||||
|
||||
@@ -335,6 +337,8 @@ async function loadList() {
|
||||
list.value = await getList(houseIdNum.value, listIdNum.value)
|
||||
}
|
||||
|
||||
const { tapRowToComplete } = useTapRowToComplete()
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSortPref()
|
||||
await Promise.all([loadList(), load(), categories.load()])
|
||||
|
||||
Reference in New Issue
Block a user