Skip to content

Commit 10bfd70

Browse files
fix(plugin-calendar): no resize-end handle without an end field; a drop that changes no date writes nothing (#29146)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb65Wst5VpQJdataZHSzkMiF)_ Fixes #29020 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:日历视图没有配置结束日期字段(`endDateField`)时,月视图卡片右侧不再画「调整结束」拖拽柄,周/日视图事件底部的「调整结束」柄也不再画;移动卡片、顶部「调整开始」柄照旧。另外,日历默认的拖放保存在「要写的日期与记录现值完全相同」时不再发写请求。做法:`CalendarView` 新增可选属性 `endEditable`(默认 true),`ObjectCalendar` 按是否声明 `endDateField` 传入。 - **为什么改**:只有开始日期的日历(如 showcase 任务日历)上,拖结束柄什么都不改,却把原样的开始日期写回去:`updated_at` 变了,所有「记录更新后」触发器和流程都为一次没人做的修改跑了一遍(通知、审计行、集成)。周视图里在事件上按下再松开(不移动)也会这样写一次。 - **风险与回滚**:低。声明了结束字段的视图两个柄都保留、照旧写结束日期(结束值为空的记录也能拖出结束日期);独立使用 `CalendarView` 不传新属性时行为不变。回滚即还原本 PR。 - **先看**:`ObjectCalendar.tsx` 的 `handleEventDropDefault` 开头新增的「无变化不写」判断与 `sameInstant`;`CalendarView.tsx` 月视图 `showResizeHandle` 与周/日视图底部柄的条件;`ObjectCalendar.tsx` 渲染 `CalendarView` 处新增的 `endEditable`。 - **两分钟验证**:在 `ui/` 下运行 `pnpm exec vitest run packages/plugin-calendar/src/__tests__/ObjectCalendar.endEditable-29020.test.tsx`,预期 1 个文件、13 个测试全绿。 - **需要你做的**:无,席位落地。 ## What changes - **`CalendarViewProps.endEditable?: boolean`** (default `true`; the type is exported from the `@object-ui/plugin-calendar` entry, hence the widening declaration). When `false`: - the month view draws no "Resize event end" handle, and a `resize-end` drag payload dropped anyway hands nothing to `onEventDrop`; - the week and day views draw no bottom "Resize end" handle. Moving a card and the time grid's top "Resize start" handle are unchanged. - **`ObjectCalendar`** passes `endEditable={Boolean(calendarConfig.endDateField)}`. A view that declares an end field keeps both handles and keeps writing the end, including on a record whose end is empty. - **No write for a drop that changes no date.** `ObjectCalendar`'s default drop handler (`handleEventDropDefault`) returns before the optimistic update and `dataSource.update` when the start it would write is the record's start, and either it writes no end or that end is the record's end. Both are compared as instants, read the way the grid placed the event (`toDisplayDate`), so a stored spelling that differs from the one written back still counts as unchanged; an empty or unparseable stored value never matches, so it still writes. This closes the second door to the same write: a press and release on a week-view event, without moving it, calls `onEventDrop` with the event's own dates and wrote them back, with or without an end field. A host that supplies its own `onEventDrop` is unaffected. ## Tests New file `ui/packages/plugin-calendar/src/__tests__/ObjectCalendar.endEditable-29020.test.tsx`, 13 cases: - **Month view, `ObjectCalendar`:** no end field draws no "Resize event end" handle; no end field still moves the card and writes the start alone. Controls: with an end field the handle is drawn and dragging it writes the new end; with an end field and an empty end on the record, the handle is drawn and writes the end. - **Week view, `ObjectCalendar`:** no end field (with `allDayField` declared, so the record is drawn timed) draws the top handle and no bottom handle. Control: with an end field, both handles. - **No-op drop:** a press and release on a week-view event writes nothing, both with no end field and with one. Control: the same event moved two hours writes its new start. - **`CalendarView` directly:** `endEditable` defaults to drawing the handle; `false` hides the month handle while the card still moves; `false` refuses a `resize-end` payload; `false` hides the week view's bottom handle and keeps the top one. ## Acceptance notes - With no `endDateField` and no `allDayField`, `ObjectCalendar` marks every record all-day, so the week and day views draw it in the all-day lane, which has no handles. The bottom-handle change matters only when the view also declares `allDayField` and a record is flagged not all-day. - With no end field, the time grid's top "Resize start" handle still writes the dragged start, and the event is then drawn an hour long from it, so it reads as a move rather than a resize. It writes a real change, so it is left as it is and reported to the seat. - `calendar-view-renderer.tsx` (the schema-driven `calendar-view` element) does not forward the new prop, so authored `calendar-view` nodes keep the default `true`, as before. ## Verification All readings on head `88ee38a1` (fix `ce572f2f`, changeset declaration `50f1987b`, test fixture `88ee38a1`), base `5d3667e2`. <details><summary>Tests</summary> - New file alone: 13 of 13 passed. - Red on main: with `CalendarView.tsx` and `ObjectCalendar.tsx` restored from `5d3667e2` (the tests import the source, no rebuild involved), the new file ran 7 failed and 6 passed. Every case asserting the new behaviour failed; the six controls passed. Restored with `git checkout HEAD`, `git diff HEAD` empty, blob equal to HEAD. - Ablation of the no-op guard: `scripts/ablation-replace.mjs` turned the guard's first condition into `false && …` (anchor 1 to 0, blob moved); the two press-and-release cases failed, 11 passed; restored, `git diff HEAD` empty. - Full package: `pnpm --dir ui exec vitest run --maxWorkers=2 packages/plugin-calendar/` under the verify lock: 67 files passed, 490 tests passed, 95 skipped (the zone-driven cases that run only in their forks child). - `pnpm exec turbo run type-check --filter=@object-ui/plugin-calendar`: 50 of 50 tasks successful. </details> <details><summary>Gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands` derived 34 families; all 34 ran with exit 0 on head `88ee38a1`, the 33 unlocked ones with this body as the PR event (`check-adr-0087-registration`, `check-changeset-no-major`, `check-closing-keyword-parity`, `check-empty-changeset`, `check-sdui-manifest` among them), and `pnpm --dir ui exec vitest run scripts/` locked: 169 files passed, 2 skipped (4,900 tests passed). - `dispatch-gates --ran`: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. - `node ui/scripts/check-new-cross-file-line-citations.mjs`: 0 new citations. - `eslint` on the three changed ui files: 0 errors; 52 warnings, the same counts (24 and 28) the two source files carry at the base, and none in the new test file. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jTP5DLZwaztvKMgUJf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1e4841a commit 10bfd70

4 files changed

Lines changed: 406 additions & 3 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/plugin-calendar': patch
3+
---
4+
5+
fix(plugin-calendar): a calendar view with no `endDateField` draws no resize-end handle, and a drop that changes no date writes nothing
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
On a calendar view that declares only `startDateField`, every month-view card still drew the "Resize event end" handle. Dragging it moved nothing, yet wrote the record's unchanged start date, so `updated_at` moved and every after-update trigger and flow ran for an edit nobody made.
11+
12+
- **No end field, no end handle.** `CalendarView` takes a new optional `endEditable` prop (default `true`). When it is `false`, the month view draws no "Resize event end" handle and the week and day views draw no bottom "Resize end" handle; moving a card and the top "Resize start" handle are unchanged. `ObjectCalendar` passes `false` when the view declares no `endDateField`. A view that declares one keeps both handles and keeps writing the end, including on a record whose end is empty.
13+
- **A drop that changes nothing writes nothing.** `ObjectCalendar`'s default drop handler now skips the write when every date it would write is where the record already has it. A press and release on a week-view event without moving it was such a drop, with or without an end field.

‎ui/packages/plugin-calendar/src/CalendarView.tsx‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,14 @@ export interface CalendarViewProps {
158158
/** Granularity in minutes used by WeekView/DayView for snapping
159159
* drag-to-move, drag-to-resize, and drag-to-create. Defaults to 30. */
160160
slotMinutes?: number
161+
/**
162+
* Whether a gesture may move an event's END on its own: the month view's
163+
* right-edge "Resize event end" handle and the week/day views' bottom resize
164+
* handle. Defaults to `true`. A host whose events have no stored end passes
165+
* `false` (`ObjectCalendar` does when the view declares no `endDateField`):
166+
* an end-only edit has nothing to write there, so neither handle is drawn.
167+
*/
168+
endEditable?: boolean
161169
className?: string
162170
}
163171

@@ -174,6 +182,7 @@ function CalendarView({
174182
onEventDrop,
175183
onTimeRangeSelect,
176184
slotMinutes = 30,
185+
endEditable = true,
177186
className,
178187
}: CalendarViewProps) {
179188
const [selectedView, setSelectedView] = React.useState(view)
@@ -442,6 +451,7 @@ function CalendarView({
442451
onEventClick={onEventClick}
443452
onDateClick={onDateClick}
444453
onEventDrop={onEventDrop}
454+
endEditable={endEditable}
445455
/>
446456
)}
447457
{selectedView === "week" && (
@@ -455,6 +465,7 @@ function CalendarView({
455465
onEventClick={onEventClick}
456466
onEventDrop={onEventDrop}
457467
onTimeRangeSelect={onTimeRangeSelect}
468+
endEditable={endEditable}
458469
/>
459470
)}
460471
{selectedView === "day" && (
@@ -468,6 +479,7 @@ function CalendarView({
468479
onEventClick={onEventClick}
469480
onEventDrop={onEventDrop}
470481
onTimeRangeSelect={onTimeRangeSelect}
482+
endEditable={endEditable}
471483
/>
472484
)}
473485
</div>
@@ -539,9 +551,11 @@ interface MonthViewProps {
539551
onEventClick?: (event: CalendarViewEvent) => void
540552
onDateClick?: (date: Date) => void
541553
onEventDrop?: (event: CalendarViewEvent, newStart: Date, newEnd?: Date) => void
554+
/** {@link CalendarViewProps.endEditable}: `false` draws no resize-end handle. */
555+
endEditable: boolean
542556
}
543557

544-
function MonthView({ date, events, locale = "default", weekStart, onEventClick, onDateClick, onEventDrop }: MonthViewProps) {
558+
function MonthView({ date, events, locale = "default", weekStart, onEventClick, onDateClick, onEventDrop, endEditable }: MonthViewProps) {
545559
const days = React.useMemo(() => getMonthDays(date, weekStart), [date.getFullYear(), date.getMonth(), weekStart])
546560
const today = React.useMemo(() => new Date(), [])
547561
const { t } = useCalendarTranslation()
@@ -675,6 +689,9 @@ function MonthView({ date, events, locale = "default", weekStart, onEventClick,
675689
newTargetDay.setHours(0, 0, 0, 0)
676690

677691
if (payload.mode === "resize-end") {
692+
// An end that is not editable has no handle; a payload naming the mode
693+
// anyway writes nothing.
694+
if (!endEditable) return
678695
// Resize: move the end to the drop-target day, keep start fixed. The
679696
// handle sits on the span's end cell, so the grabbed cell is the end's
680697
// own day, and the end moves by the calendar days from it to the drop
@@ -784,7 +801,9 @@ function MonthView({ date, events, locale = "default", weekStart, onEventClick,
784801
{dayEvents.slice(0, 4).map(({ event, isTitleDay, isSpanStart, isSpanEnd }, slotIdx) => {
785802
const isContinuation = !isTitleDay
786803
const { className: colorCls, inlineColor } = resolveEventColor(event.color)
787-
const showResizeHandle = !!onEventDrop && isSpanEnd
804+
// No handle where the end is not editable: dragging it would
805+
// hand back the unchanged start and nothing else.
806+
const showResizeHandle = !!onEventDrop && endEditable && isSpanEnd
788807
return (
789808
<div
790809
key={`${event.id}-${slotIdx}`}
@@ -867,6 +886,8 @@ interface TimeGridViewProps {
867886
onEventClick?: (event: CalendarViewEvent) => void
868887
onEventDrop?: (event: CalendarViewEvent, newStart: Date, newEnd?: Date) => void
869888
onTimeRangeSelect?: (start: Date, end: Date) => void
889+
/** {@link CalendarViewProps.endEditable}: `false` draws no bottom resize handle. */
890+
endEditable: boolean
870891
}
871892

872893
/**
@@ -1002,6 +1023,7 @@ function TimeGridView({
10021023
onEventClick,
10031024
onEventDrop,
10041025
onTimeRangeSelect,
1026+
endEditable,
10051027
}: TimeGridViewProps) {
10061028
const { t } = useCalendarTranslation()
10071029
const slotPx = (PX_PER_HOUR * slotMinutes) / 60
@@ -1584,7 +1606,7 @@ function TimeGridView({
15841606
aria-label={t('calendar.a11y.resizeStart')}
15851607
/>
15861608
)}
1587-
{onEventDrop && entry.isEnd && (
1609+
{onEventDrop && endEditable && entry.isEnd && (
15881610
<div
15891611
onPointerDown={(e) => handleEventPointerDown(e, entry, dayIndex, "bottom")}
15901612
className="absolute bottom-0 left-0 right-0 h-1.5 cursor-ns-resize"

‎ui/packages/plugin-calendar/src/ObjectCalendar.tsx‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,18 @@ function toStoredDateValue(date: Date, declaredType: unknown, stored: unknown):
361361
return isDateOnlyField(declaredType, stored) ? toDateInputValue(date) : date.toISOString();
362362
}
363363

364+
/**
365+
* Does `stored` name the instant `date` is? Read the way the grid read it to
366+
* place the event (`toDisplayDate`); an empty or unparseable value never
367+
* matches, so a drop onto a record with no value there still writes.
368+
*/
369+
function sameInstant(date: Date, stored: unknown): boolean {
370+
if (typeof stored !== 'string' && typeof stored !== 'number' && !(stored instanceof Date)) return false;
371+
if (stored === '') return false;
372+
const read = toDisplayDate(stored);
373+
return !isNaN(read.getTime()) && read.getTime() === date.getTime();
374+
}
375+
364376
/** Does this date field hold a calendar day? {@link toStoredDateValue}'s split. */
365377
function isDateOnlyField(declaredType: unknown, stored: unknown): boolean {
366378
return typeof declaredType === 'string'
@@ -1272,6 +1284,19 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
12721284
const id = record?.id ?? record?._id;
12731285
if (!id || !schema.objectName || !dataSource?.update) return;
12741286

1287+
// A drop that leaves every date it would write where the record already
1288+
// has it writes nothing: a write of unchanged values still moves
1289+
// `updated_at` and runs every after-update trigger and flow. Compared as
1290+
// the instants the grid placed the event at (`toDisplayDate`), so a stored
1291+
// spelling that differs from the one written back is still "unchanged".
1292+
const writesEnd = Boolean(endDateField && newEnd);
1293+
if (
1294+
sameInstant(newStart, record?.[startDateField]) &&
1295+
(!writesEnd || sameInstant(newEnd as Date, record?.[endDateField as string]))
1296+
) {
1297+
return;
1298+
}
1299+
12751300
// A `date` field is written as the calendar day, a `datetime` as the
12761301
// instant (objectui#10866, `toStoredDateValue`). The month grid moves a
12771302
// value by calendar days and keeps its time of day (objectui#11005), so a
@@ -1666,6 +1691,9 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
16661691
: undefined
16671692
}
16681693
onTimeRangeSelect={rangeSelectCreate}
1694+
// With no `endDateField` the end is not stored, so no gesture may
1695+
// move it on its own: no resize-end handle is drawn.
1696+
endEditable={Boolean(calendarConfig.endDateField)}
16691697
/>
16701698
</div>
16711699
{/* objectui#7210 — a month drawn from the first N rows of a larger set

0 commit comments

Comments
 (0)