Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions lib/dialog/SharingDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,8 @@
* Switch to the confirmation view with the submitted share's link.
*
* @param payload The resolved link and whether it is a public link
* @param payload.link

Check warning on line 158 in lib/dialog/SharingDialog.vue

View workflow job for this annotation

GitHub Actions / eslint

Missing JSDoc @PARAM "payload.link" description
* @param payload.isPublic

Check warning on line 159 in lib/dialog/SharingDialog.vue

View workflow job for this annotation

GitHub Actions / eslint

Missing JSDoc @PARAM "payload.isPublic" description
*/
function onSubmitted(payload: { link: string | null, isPublic: boolean }) {
submitResult.value = payload
Expand Down Expand Up @@ -216,9 +216,6 @@
// changes (switching tabs, adding recipients, revealing toggles).
min-height: min(320px, 50vh);
overflow-y: auto;
// Match the dialog's inline padding at the bottom (its content has none),
// so the form does not sit flush against the edge.
padding-block-end: calc(var(--default-grid-baseline) * 3);
}

&__loading,
Expand Down
7 changes: 1 addition & 6 deletions lib/dialog/components/InlineToggleField.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const TOGGLE_INPUT = '.inline-toggle-field__toggle input'
*
* @param props Component props
*/
function mountField(props: { modelValue: boolean, label?: string, longText?: boolean }): VueWrapper {
function mountField(props: { modelValue: boolean, label?: string }): VueWrapper {
return mount(InlineToggleField, {
props: { label: 'Note', ...props },
slots: {
Expand Down Expand Up @@ -102,11 +102,6 @@ describe('InlineToggleField', () => {
await expect(wrapper.find(TOGGLE_INPUT).setValue(true)).resolves.not.toThrow()
})

it('adds the long-text class on the toggle when longText is set', () => {
const wrapper = mountField({ modelValue: true, longText: true })
expect(wrapper.find('.inline-toggle-field__toggle--long-text').exists()).toBe(true)
})

it('exposes the group aria-label and wires the slot input id', () => {
const wrapper = mountField({ modelValue: true, label: 'Expiration' })
expect(wrapper.find('[role="group"]').attributes('aria-label')).toBe('Expiration')
Expand Down
8 changes: 0 additions & 8 deletions lib/dialog/components/InlineToggleField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
:aria-controls="inputId"
:aria-label="label"
class="inline-toggle-field__toggle"
:class="{ 'inline-toggle-field__toggle--long-text': longText }"
type="switch"
@update:modelValue="onToggleEnabled" />
</div>
Expand All @@ -39,8 +38,6 @@ const modelValue = defineModel<boolean>({ default: false })
defineProps<{
/** Accessible label of the field group and its toggle */
label: string
/** Align the toggle to the first line of a multi-line field (e.g. textarea) */
longText?: boolean
}>()

const inputId = `property-input-${Math.random().toString(36).slice(2, 9)}`
Expand Down Expand Up @@ -87,11 +84,6 @@ async function onToggleEnabled(enabled: boolean) {

&__toggle {
height: var(--default-clickable-area);

&--long-text {
align-self: flex-start;
margin-top: 6px;
}
}
}
</style>
28 changes: 28 additions & 0 deletions lib/dialog/components/PropertyField.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,34 @@ describe('PropertyField persistence', () => {
expect(mockedUpdate).toHaveBeenCalledWith(PROPERTY_CLASS, 'Hey')
})

it('unsets the property when the field is cleared', async () => {
const wrapper = mount(PropertyField, {
props: { property: property({ value: 'Old note' }), share: shareMock, modelValue: 'Old note' },
attachTo: document.body,
})
const input = wrapper.find('input[type="text"]')
vi.spyOn(input.element as HTMLInputElement, 'checkValidity').mockReturnValue(true)

// There is no toggle to switch off: clearing the text is how a property
// without one gets unset.
await input.setValue('')
await vi.advanceTimersByTimeAsync(500)

expect(mockedUpdate).toHaveBeenCalledWith(PROPERTY_CLASS, null)
})

it('dispatches a pending edit when the field goes away', async () => {
const wrapper = mountField()
const input = wrapper.find('input[type="text"]')
vi.spyOn(input.element as HTMLInputElement, 'checkValidity').mockReturnValue(true)

await input.setValue('Hello')
// Closing the dialog right after typing must not drop the edit.
wrapper.unmount()

expect(mockedUpdate).toHaveBeenCalledWith(PROPERTY_CLASS, 'Hello')
})

it('skips the request and reports validity when the input is invalid', async () => {
const wrapper = mountField()
const input = wrapper.find('input[type="text"]')
Expand Down
6 changes: 5 additions & 1 deletion lib/dialog/components/PropertyField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ import type { SharingProperty } from '../types/api.ts'

import IconInformationOutline from '@mdi/svg/svg/information-outline.svg?raw'
import debounce from 'debounce'
import { nextTick, ref, useTemplateRef } from 'vue'
import { nextTick, onBeforeUnmount, ref, useTemplateRef } from 'vue'
import NcDateTimePickerNative from '@nextcloud/vue/components/NcDateTimePickerNative'
import NcFormBox from '@nextcloud/vue/components/NcFormBox'
import NcFormBoxSwitch from '@nextcloud/vue/components/NcFormBoxSwitch'
Expand Down Expand Up @@ -161,6 +161,10 @@ function parseISODate(value: string | null | undefined): Date | undefined {

const debouncedPersist = debounce(persistValue, 500)

// A field without a toggle is unset by clearing it, so a pending edit must not
// be lost when the dialog closes right after typing.
onBeforeUnmount(() => debouncedPersist.flush())

/**
* Update the local value immediately and schedule a debounced persist.
* Converts component-native values (boolean, Date) to string for the API.
Expand Down
35 changes: 34 additions & 1 deletion lib/dialog/components/SharePanel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { SharingShare } from '../types/api.ts'
import { flushPromises, shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SharePanel from './SharePanel.vue'
import { PROPERTY_EXPIRATION, PROPERTY_PASSWORD, RECIPIENT_TYPE_TOKEN, RECIPIENT_TYPE_USER, SOURCE_TYPE_NODE } from '../constants.ts'
import { PROPERTY_EXPIRATION, PROPERTY_NOTE, PROPERTY_PASSWORD, RECIPIENT_TYPE_TOKEN, RECIPIENT_TYPE_USER, SOURCE_TYPE_NODE } from '../constants.ts'
import { ShareDialogTab } from '../types/ui.ts'

const PRESET_VIEW = 'preset-view'
Expand Down Expand Up @@ -278,6 +278,39 @@ describe('SharePanel submit', () => {
})
})

describe('SharePanel properties', () => {
/** A note property: free text, long enough to render as a textarea. */
const note = (value: string | null) => ({
class: PROPERTY_NOTE,
display_name: 'Note to recipients',
hint: null,
priority: 9,
required: false,
max_length: 1000,
value,
type: 'string' as const,
})

it('offers free text without a toggle, editable while empty', () => {
const { wrapper } = mountPanel(schema({ properties: [note(null)] }))

// Nothing to switch on first: an empty field already says "no note".
expect(wrapper.findComponent({ name: 'InlineToggleField' }).exists()).toBe(false)
const field = wrapper.findComponent({ name: 'PropertyField' })
expect(field.exists()).toBe(true)
expect(field.props('disabled')).toBeFalsy()
})

it('keeps the toggle for properties that are not free text', () => {
const { wrapper } = mountPanel(schema({
properties: [{ class: PROPERTY_PASSWORD, display_name: 'Password', hint: null, priority: 6, required: false, value: null, type: 'password' }],
}), { inSettings: true })

// A password is meaningfully "on with no value yet", so it keeps its switch.
expect(wrapper.findComponent({ name: 'InlineToggleField' }).exists()).toBe(true)
})
})

describe('SharePanel notes', () => {
it('summarizes expiration and password when both are set', () => {
const data = schema({
Expand Down
9 changes: 5 additions & 4 deletions lib/dialog/components/SharePanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,8 @@
<!-- First-page properties (e.g. Note to recipients) -->
<template v-for="property in firstPageProperties" :key="property.class">
<InlineToggleField
v-if="isOptionalProperty(property)"
v-if="isOptionalProperty(property) && !isLongTextProperty(property)"
:label="property.display_name"
:longText="isLongTextProperty(property)"
:modelValue="property.value !== null"
@update:modelValue="(enabled) => toggleOptionalProperty(property, enabled)">
<template #default="{ inputId }">
Expand Down Expand Up @@ -140,9 +139,8 @@

<template v-for="property in settingsProperties" :key="property.class">
<InlineToggleField
v-if="isOptionalProperty(property)"
v-if="isOptionalProperty(property) && !isLongTextProperty(property)"
:label="property.display_name"
:longText="isLongTextProperty(property)"
:modelValue="property.value !== null"
@update:modelValue="(enabled) => toggleOptionalProperty(property, enabled)">
<template #default="{ inputId }">
Expand Down Expand Up @@ -469,7 +467,10 @@ form.share-panel {
z-index: 2;
background-color: var(--color-main-background);
border-block-start: 1px solid var(--color-border);
// Match the form's vertical padding so the bar does not float above the content.
padding-block: calc(var(--default-grid-baseline) * 3);
// Stick it to the bottom of the form
margin-block-start: auto;
// Cancel the form's bottom padding so the bar sits flush at the bottom.
margin-block-end: calc(var(--default-grid-baseline) * -3);
}
Expand Down
Loading