feat(project): 支持表格分组配置与悬停筛选菜单

新增到期、排期、逾期与未排期分组,按项目缓存显示选择并至少保留一项。

统一分组复选下拉菜单,接入 Cascader 桌面悬停展开,增加勾选后的滚动定位校正。

升级 view-design-hi 至 4.7.0-81,同步多语言、知识库和回归测试。
This commit is contained in:
kuaifan 2026-09-07 12:08:52 +00:00
parent a3bfc74159
commit ffbb45708d
25 changed files with 1240 additions and 29 deletions

View File

@ -2741,3 +2741,9 @@ WebDAV 路径冲突
确定要删除该优先级吗? 确定要删除该优先级吗?
登录页 Logo 登录页 Logo
用于 Web 端和客户端登录页,未设置时使用默认 Logo。 用于 Web 端和客户端登录页,未设置时使用默认 Logo。
本周到期
下周到期
本周排期
下周排期
已逾期
未排期

View File

@ -39610,5 +39610,77 @@
"fr": "Utilisé sur les pages de connexion Web et client. Le logo par défaut est utilisé si aucun logo n'est configuré.", "fr": "Utilisé sur les pages de connexion Web et client. Le logo par défaut est utilisé si aucun logo n'est configuré.",
"id": "Digunakan pada halaman login Web dan klien. Logo default digunakan jika tidak dikonfigurasi.", "id": "Digunakan pada halaman login Web dan klien. Logo default digunakan jika tidak dikonfigurasi.",
"ru": "Используется на страницах входа веб-версии и клиента. Если не настроено, используется логотип по умолчанию." "ru": "Используется на страницах входа веб-версии и клиента. Если не настроено, используется логотип по умолчанию."
},
{
"key": "本周到期",
"zh": "",
"zh-CHT": "本週到期",
"en": "Due this week",
"ko": "이번 주 마감",
"ja": "今週が期限",
"de": "Diese Woche fällig",
"fr": "À échéance cette semaine",
"id": "Jatuh tempo minggu ini",
"ru": "Срок на этой неделе"
},
{
"key": "下周到期",
"zh": "",
"zh-CHT": "下週到期",
"en": "Due next week",
"ko": "다음 주 마감",
"ja": "来週が期限",
"de": "Nächste Woche fällig",
"fr": "À échéance la semaine prochaine",
"id": "Jatuh tempo minggu depan",
"ru": "Срок на следующей неделе"
},
{
"key": "本周排期",
"zh": "",
"zh-CHT": "本週排期",
"en": "Scheduled this week",
"ko": "이번 주 예정",
"ja": "今週の予定",
"de": "Diese Woche geplant",
"fr": "Planifiées cette semaine",
"id": "Dijadwalkan minggu ini",
"ru": "Запланировано на эту неделю"
},
{
"key": "下周排期",
"zh": "",
"zh-CHT": "下週排期",
"en": "Scheduled next week",
"ko": "다음 주 예정",
"ja": "来週の予定",
"de": "Nächste Woche geplant",
"fr": "Planifiées la semaine prochaine",
"id": "Dijadwalkan minggu depan",
"ru": "Запланировано на следующую неделю"
},
{
"key": "已逾期",
"zh": "",
"zh-CHT": "已逾期",
"en": "Overdue",
"ko": "기한 초과",
"ja": "期限超過",
"de": "Überfällig",
"fr": "En retard",
"id": "Lewat tenggat",
"ru": "Просроченные"
},
{
"key": "未排期",
"zh": "",
"zh-CHT": "未排期",
"en": "Unscheduled",
"ko": "일정 미정",
"ja": "予定未設定",
"de": "Nicht geplant",
"fr": "Non planifiées",
"id": "Belum dijadwalkan",
"ru": "Не запланированы"
} }
] ]

View File

@ -67,7 +67,7 @@
"stylus-loader": "^7.1.0", "stylus-loader": "^7.1.0",
"tinymce": "^5.10.3", "tinymce": "^5.10.3",
"tui-calendar-hi": "^2.1.3-6", "tui-calendar-hi": "^2.1.3-6",
"view-design-hi": "^4.7.0-80", "view-design-hi": "^4.7.0-81",
"vite": "^2.9.15", "vite": "^2.9.15",
"vite-plugin-file-copy": "^1.0.0", "vite-plugin-file-copy": "^1.0.0",
"vite-plugin-require": "^1.1.10", "vite-plugin-require": "^1.1.10",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -20,14 +20,15 @@ negative:
- 一次只能选一个筛选条件(不支持「负责人 A + 标签 B」组合 - 一次只能选一个筛选条件(不支持「负责人 A + 标签 B」组合
- 移动端的筛选 Cascader 与桌面端共用,但下拉空间有限 - 移动端的筛选 Cascader 与桌面端共用,但下拉空间有限
- 没有「保存常用筛选」功能 - 没有「保存常用筛选」功能
last_verified: v1.7.90 last_verified: v1.9.18
--- ---
# 视图筛选条件 # 视图筛选条件
## 入口 ## 入口
- 桌面端 Web项目详情页顶部视图切换条左侧的 Cascader默认显示「全部 (n)」) - 桌面端 Web项目详情页顶部视图切换条左侧的 Cascader默认显示「全部 (n)」)
- 移动端:项目详情页顶部同位置 - 桌面非触屏端:鼠标悬停展开,移入下拉菜单保持展开,移出入口与菜单后延迟关闭
- 触屏设备和 App项目详情页顶部同位置点击展开
## 可用筛选维度 ## 可用筛选维度
- **全部**:不筛选 - **全部**:不筛选

View File

@ -21,14 +21,14 @@ negative:
- 一个项目可以有多条工作流,但筛选 Cascader 一次只能选一个工作流的某个状态 - 一个项目可以有多条工作流,但筛选 Cascader 一次只能选一个工作流的某个状态
- 项目未配置工作流时筛选下拉只显示「全部」与「未计划」 - 项目未配置工作流时筛选下拉只显示「全部」与「未计划」
- 工作流状态flow_item不等于看板列column两者独立 - 工作流状态flow_item不等于看板列column两者独立
last_verified: v1.7.90 last_verified: v1.9.18
--- ---
# 按工作流状态分栏查看任务 # 按工作流状态分栏查看任务
## 入口 ## 入口
- 桌面端 Web项目详情页顶部「工作流」Cascader默认显示「全部 (n)」) - 桌面端 Web项目详情页顶部「工作流」Cascader默认显示「全部 (n)」)
- 点开下拉 → 选某个工作流的某个状态(如「评审 → 评审中」) - 桌面非触屏端悬停展开下拉;触屏设备和 App 点击展开,再选择某个工作流的某个状态(如「评审 → 评审中」)
## 操作步骤 ## 操作步骤
1. 确保项目已配置工作流(项目设置 → 工作流设置;详见 [[project.flow.howto.create]] 1. 确保项目已配置工作流(项目设置 → 工作流设置;详见 [[project.flow.howto.create]]

View File

@ -12,6 +12,9 @@ aliases:
- 表格视图 - 表格视图
- 按行看任务 - 按行看任务
- table 视图 - table 视图
- 怎么选择本周任务和下周到期
- 怎么看本周排期和逾期任务
- 隐藏协助任务分组
related_tools: [list_tasks] related_tools: [list_tasks]
related_pages: [project_detail] related_pages: [project_detail]
prerequisites: [] prerequisites: []
@ -20,7 +23,8 @@ negative:
- 不支持双击编辑(点击任务行打开任务详情) - 不支持双击编辑(点击任务行打开任务详情)
- 没有 Excel 式选区批量操作 - 没有 Excel 式选区批量操作
- 行内排序仅 优先级 / 到期时间 两列可点 - 行内排序仅 优先级 / 到期时间 两列可点
last_verified: v1.7.90 - 至少保留一个分组,不能全部取消
last_verified: v1.9.18
--- ---
# 使用列表视图查看任务 # 使用列表视图查看任务
@ -30,14 +34,28 @@ last_verified: v1.7.90
- 视图切换记忆在 `cacheParameter.menuType='table'`,下次进入同一项目仍是列表视图 - 视图切换记忆在 `cacheParameter.menuType='table'`,下次进入同一项目仍是列表视图
## 列表分段 ## 列表分段
切到列表视图后,任务自动按归属分成 4 段: 先切换到表格模式,鼠标悬停视图切换组前的设置齿轮图标,下拉菜单中可复选分组。菜单与项目筛选使用一致的弹层外观,无箭头和标题栏。触屏先切换表格,再点击齿轮打开选择。表格图标仅切换视图,悬停或点击都不会打开分组菜单。看板和甘特图模式不显示设置齿轮;切走表格时菜单关闭。
1. **我的任务**:当前用户是负责人的任务 | 分组 | 默认显示 | 范围 |
2. **协助的任务**:当前用户是协助者的任务(仅当存在时显示) |---|---|---|
3. **未完成任务**:项目内其他未完成任务 | 我的任务 | 是 | 当前用户负责的任务 |
4. **已完成任务**:项目内已完成任务(仅当 task_num > 0 时显示) | 协助的任务 | 是 | 当前用户协助的主任务 |
| 未完成任务 | 是 | 项目内未完成主任务,包含本人任务 |
| 已完成任务 | 否 | 项目内已完成主任务 |
| 本周到期 / 下周到期 | 否 | 到期时间落在对应周的主任务 |
| 本周排期 / 下周排期 | 否 | 计划开始、结束时间完整且与对应周相交的主任务 |
| 已逾期 | 否 | 未完成,且到期时间早于当前时间的主任务 |
| 未排期 | 否 | 未完成,且缺少计划开始时间或结束时间的主任务 |
每段标题旁有箭头可折叠 / 展开,状态记忆在 `cacheParameter.showMy / showHelp / showUndone / showCompleted` 每周从周一零点到下周一零点(不含),使用系统现有时区口径。跨周计划可同时出现在两周分组,同一任务也可出现在多个分组;子任务沿用行内展开。
所有分组均可取消,但至少保留一项。勾选后即使数量为 0 也显示标题和空状态,不再按是否有任务隐藏。标题箭头独立控制折叠;新勾选自动展开,并在表格内滚动到该分组标题(靠近底部时以最大可滚动位置为限)。取消勾选不触发定位;横向滚动位置和顶部设置入口保持不变,菜单保持打开,可继续复选。
定位会等待进入视口的任务渲染,再重新测量并校正高度变化造成的偏移。初次滚动平滑,后续校正不重复启动动画;位置稳定后停止,最长持续 2.5 秒。手动滚轮、触屏、拖动滚动条、滚动按键,以及更改分组选择、切换项目或视图会中断旧定位。超时后不再持续追踪后续高度变化。
显示选择存于项目本地缓存 `cacheParameter.tableGroups`,与视图选择同一缓存机制,不跨设备同步;不覆盖原有折叠状态。搜索和顶部筛选仍生效。“显示已完成”影响我的、协助及四个周分组,不影响独立的已完成分组。
“本周排期 / 下周排期”原名为“本周任务 / 下周任务”,仅修改名称,原有选择和折叠缓存保留。已逾期与未排期始终只含未完成任务,不受“显示已完成”开启影响;已逾期会随当前时间自动更新。
## 列结构 ## 列结构
列表视图列固定为: 列表视图列固定为:

View File

@ -19,7 +19,7 @@ negative:
- 没有快捷键切换视图 - 没有快捷键切换视图
- 移动端视图切换按钮位置与桌面一致,但小屏更靠右 - 移动端视图切换按钮位置与桌面一致,但小屏更靠右
- 工作流视图不是这里切换,是通过工作流筛选 Cascader 叠加 - 工作流视图不是这里切换,是通过工作流筛选 Cascader 叠加
last_verified: v1.7.90 last_verified: v1.9.18
--- ---
# 切换看板 / 列表 / 甘特图视图 # 切换看板 / 列表 / 甘特图视图
@ -37,6 +37,8 @@ last_verified: v1.7.90
切换器底部有一条滑块slider会跟随高亮所选视图按钮变色。 切换器底部有一条滑块slider会跟随高亮所选视图按钮变色。
仅在表格模式下,悬停切换组前的设置齿轮图标会弹出分组复选下拉菜单;触屏切换表格后点击齿轮打开。菜单与项目筛选弹层风格一致,无箭头和标题栏,勾选后保持打开。列表图标只切换视图,不触发菜单。切走表格后齿轮隐藏、菜单关闭。可选择我的、协助、未完成、已完成、本周到期、下周到期、本周排期、下周排期、已逾期、未排期,至少保留一项。默认只选择我的、协助、未完成,其余默认不选。选择按项目本地缓存,与折叠状态独立,详见 [[view.list.howto]]。
## 操作步骤 ## 操作步骤
1. 进入项目详情页 1. 进入项目详情页
2. 点切换条上对应图标 2. 点切换条上对应图标

View File

@ -101,10 +101,27 @@
<Checkbox :value="projectData.cacheParameter.completedTask" @on-change="toggleParameter('completedTask')">{{$L('显示已完成')}}</Checkbox> <Checkbox :value="projectData.cacheParameter.completedTask" @on-change="toggleParameter('completedTask')">{{$L('显示已完成')}}</Checkbox>
</div> </div>
<div class="project-select"> <div class="project-select">
<Cascader ref="flow" :data="flowData" @on-change="flowChange" transfer-class-name="project-panel-flow-cascader" transfer> <Cascader
ref="flow"
:data="flowData"
:open-trigger="$isEEUIApp || windowTouch ? 'click' : 'hover'"
@on-change="flowChange"
transfer-class-name="project-panel-flow-cascader"
transfer>
<span :class="`project-flow ${flowInfo.status || ''}`" :style="flowInfo.style">{{ flowTitle }}</span> <span :class="`project-flow ${flowInfo.status || ''}`" :style="flowInfo.style">{{ flowTitle }}</span>
</Cascader> </Cascader>
</div> </div>
<ProjectTableGroupDropdown
v-if="tabTypeActive === 'table'"
v-model="tableGroupsOpen"
class="project-table-settings"
:touch="$isEEUIApp || windowTouch"
:selected="visibleTableGroups"
@change="setTableGroupVisible">
<button type="button" class="project-table-settings-icon">
<Icon type="md-settings"/>
</button>
</ProjectTableGroupDropdown>
<div class="project-switch-button"> <div class="project-switch-button">
<div class="slider" :style="tabTypeStyle"></div> <div class="slider" :style="tabTypeStyle"></div>
<div @click="tabTypeChange('column')" :class="{ 'active': tabTypeActive === 'column'}"><i class="taskfont">&#xe60c;</i></div> <div @click="tabTypeChange('column')" :class="{ 'active': tabTypeActive === 'column'}"><i class="taskfont">&#xe60c;</i></div>
@ -314,7 +331,7 @@
</Row> </Row>
</div> </div>
<!--我的任务--> <!--我的任务-->
<div :class="['project-table-body', !projectData.cacheParameter.showMy ? 'project-table-hide' : '']"> <div v-if="visibleTableGroups.includes('my')" data-table-group="my" :class="['project-table-body', !projectData.cacheParameter.showMy ? 'project-table-hide' : '']">
<Row class="task-row"> <Row class="task-row">
<Col span="12" class="row-title"> <Col span="12" class="row-title">
<i class="taskfont" @click="toggleParameter('showMy')">&#xe689;</i> <i class="taskfont" @click="toggleParameter('showMy')">&#xe689;</i>
@ -334,9 +351,10 @@
open-key="my" open-key="my"
@on-priority="addTaskOpen" @on-priority="addTaskOpen"
fast-add-task/> fast-add-task/>
<div v-if="projectData.cacheParameter.showMy && !myList.length" class="project-table-empty">{{$L('暂无任务')}}</div>
</div> </div>
<!--协助的任务--> <!--协助的任务-->
<div v-if="helpList.length" :class="['project-table-body', !projectData.cacheParameter.showHelp ? 'project-table-hide' : '']"> <div v-if="visibleTableGroups.includes('help')" data-table-group="help" :class="['project-table-body', !projectData.cacheParameter.showHelp ? 'project-table-hide' : '']">
<Row class="task-row"> <Row class="task-row">
<Col span="12" class="row-title"> <Col span="12" class="row-title">
<i class="taskfont" @click="toggleParameter('showHelp')">&#xe689;</i> <i class="taskfont" @click="toggleParameter('showHelp')">&#xe689;</i>
@ -355,9 +373,10 @@
:readonly="isDepartmentReadonly" :readonly="isDepartmentReadonly"
open-key="help" open-key="help"
@on-priority="addTaskOpen"/> @on-priority="addTaskOpen"/>
<div v-if="projectData.cacheParameter.showHelp && !helpList.length" class="project-table-empty">{{$L('暂无任务')}}</div>
</div> </div>
<!--未完成任务--> <!--未完成任务-->
<div v-if="projectData.task_num > 0" :class="['project-table-body', !projectData.cacheParameter.showUndone ? 'project-table-hide' : '']"> <div v-if="visibleTableGroups.includes('undone')" data-table-group="undone" :class="['project-table-body', !projectData.cacheParameter.showUndone ? 'project-table-hide' : '']">
<Row class="task-row"> <Row class="task-row">
<Col span="12" class="row-title"> <Col span="12" class="row-title">
<i class="taskfont" @click="toggleParameter('showUndone')">&#xe689;</i> <i class="taskfont" @click="toggleParameter('showUndone')">&#xe689;</i>
@ -376,9 +395,10 @@
:readonly="isDepartmentReadonly" :readonly="isDepartmentReadonly"
open-key="undone" open-key="undone"
@on-priority="addTaskOpen"/> @on-priority="addTaskOpen"/>
<div v-if="projectData.cacheParameter.showUndone && !unList.length" class="project-table-empty">{{$L('暂无任务')}}</div>
</div> </div>
<!--已完成任务--> <!--已完成任务-->
<div v-if="projectData.task_num > 0" :class="['project-table-body', !projectData.cacheParameter.showCompleted ? 'project-table-hide' : '']"> <div v-if="visibleTableGroups.includes('completed')" data-table-group="completed" :class="['project-table-body', !projectData.cacheParameter.showCompleted ? 'project-table-hide' : '']">
<Row class="task-row"> <Row class="task-row">
<Col span="12" class="row-title"> <Col span="12" class="row-title">
<i class="taskfont" @click="toggleParameter('showCompleted')">&#xe689;</i> <i class="taskfont" @click="toggleParameter('showCompleted')">&#xe689;</i>
@ -389,7 +409,7 @@
<Col span="3"></Col> <Col span="3"></Col>
<Col span="3"></Col> <Col span="3"></Col>
<Col span="3"> <Col span="3">
<div class="ellipsis">{{projectData.task_num > 0 && projectData.cacheParameter.showCompleted ? $L('完成时间') : ''}}</div> <div class="ellipsis">{{projectData.cacheParameter.showCompleted ? $L('完成时间') : ''}}</div>
</Col> </Col>
</Row> </Row>
<TaskRow <TaskRow
@ -400,6 +420,32 @@
open-key="completed" open-key="completed"
@on-priority="addTaskOpen" @on-priority="addTaskOpen"
showCompleteAt/> showCompleteAt/>
<div v-if="projectData.cacheParameter.showCompleted && !completedList.length" class="project-table-empty">{{$L('暂无任务')}}</div>
</div>
<div
v-for="group in additionalTableGroups"
:key="group.key"
:data-table-group="group.key"
:class="['project-table-body', !projectData.cacheParameter[group.expandedKey] ? 'project-table-hide' : '']">
<Row class="task-row">
<Col span="12" class="row-title">
<i class="taskfont" @click="toggleParameter(group.expandedKey)">&#xe689;</i>
<div class="row-h1">{{$L(group.title)}}</div>
<div class="row-num">({{group.tasks.length}})</div>
</Col>
<Col span="3"></Col>
<Col span="3"></Col>
<Col span="3"></Col>
<Col span="3"></Col>
</Row>
<TaskRow
v-if="projectData.cacheParameter[group.expandedKey]"
:list="group.tasks"
:task-visibilitys="taskRowVisibilitys"
:readonly="isDepartmentReadonly"
:open-key="group.key"
@on-priority="addTaskOpen"/>
<div v-if="projectData.cacheParameter[group.expandedKey] && !group.tasks.length" class="project-table-empty">{{$L('暂无任务')}}</div>
</div> </div>
</Scrollbar> </Scrollbar>
<div v-else-if="tabTypeActive === 'gantt'" class="project-gantt"> <div v-else-if="tabTypeActive === 'gantt'" class="project-gantt">
@ -650,11 +696,14 @@ import TaskMenu from "./TaskMenu";
import TaskDeleted from "./TaskDeleted"; import TaskDeleted from "./TaskDeleted";
import TaskTag from "./ProjectTaskTag/tags.vue"; import TaskTag from "./ProjectTaskTag/tags.vue";
import ProjectGantt from "./ProjectGantt"; import ProjectGantt from "./ProjectGantt";
import ProjectTableGroupDropdown from "./ProjectTableGroupDropdown.vue";
import UserSelect from "../../../components/UserSelect.vue"; import UserSelect from "../../../components/UserSelect.vue";
import UserAvatarTip from "../../../components/UserAvatar/tip.vue"; import UserAvatarTip from "../../../components/UserAvatar/tip.vue";
import VMPreviewNostyle from "../../../components/VMEditor/nostyle.vue"; import VMPreviewNostyle from "../../../components/VMEditor/nostyle.vue";
import emitter from "../../../store/events"; import emitter from "../../../store/events";
import transformEmojiToHtml from "../../../utils/emoji"; import transformEmojiToHtml from "../../../utils/emoji";
import {projectTableGroups, normalizeProjectTableGroups, projectWeekRanges, taskMatchesProjectGroup} from "../../../utils/projectTableGroups";
import {scrollToStableTarget} from "../../../utils/scrollToStableTarget";
export default { export default {
name: "ProjectPanel", name: "ProjectPanel",
@ -677,6 +726,7 @@ export default {
TaskDeleted, TaskDeleted,
TaskTag, TaskTag,
ProjectGantt, ProjectGantt,
ProjectTableGroupDropdown,
}, },
data() { data() {
return { return {
@ -684,6 +734,8 @@ export default {
nowTime: $A.dayjs().unix(), nowTime: $A.dayjs().unix(),
nowInterval: null, nowInterval: null,
tableToday: $A.daytz().format('YYYY-MM-DD'),
tableGroupsOpen: false,
columnLoad: {}, columnLoad: {},
columnTopShow: {}, columnTopShow: {},
@ -740,10 +792,12 @@ export default {
mounted() { mounted() {
this.nowInterval = setInterval(() => { this.nowInterval = setInterval(() => {
this.nowTime = $A.dayjs().unix(); this.nowTime = $A.dayjs().unix();
this.tableToday = $A.daytz().format('YYYY-MM-DD');
}, 1000); }, 1000);
}, },
destroyed() { destroyed() {
this.cancelTableGroupScroll();
clearInterval(this.nowInterval); clearInterval(this.nowInterval);
}, },
@ -771,6 +825,35 @@ export default {
return this.projectData.cacheParameter.menuType return this.projectData.cacheParameter.menuType
}, },
tableGroupOptions() {
return projectTableGroups;
},
visibleTableGroups() {
return normalizeProjectTableGroups(this.projectData.cacheParameter.tableGroups);
},
tableWeekRanges() {
return projectWeekRanges($A.dayjs(this.tableToday));
},
additionalTableGroups() {
const groups = this.tableGroupOptions.filter(group => group.kind && this.visibleTableGroups.includes(group.key));
if (!groups.length) return [];
const tasks = this.panelTask(this.parentTask);
const now = groups.some(group => group.kind === 'overdue') ? $A.daytz(this.nowTime).valueOf() : 0;
return groups.map(group => ({
...group,
tasks: tasks.filter(task => taskMatchesProjectGroup(task, group, this.tableWeekRanges, now, $A.dayjs)).sort((a, b) => {
if (this.sortType === 'asc') [a, b] = [b, a];
if (this.sortField === 'level' || a.end_at === b.end_at) {
return $A.sortFloat(a.p_level, b.p_level);
}
return $A.sortDay(a.end_at || '2099-12-31 23:59:59', b.end_at || '2099-12-31 23:59:59');
}),
}));
},
tabTypeStyle() { tabTypeStyle() {
const style = {} const style = {}
switch (this.tabTypeActive) { switch (this.tabTypeActive) {
@ -1182,8 +1265,23 @@ export default {
}, },
watch: { watch: {
tabTypeActive(type) {
if (type !== 'table') {
this.tableGroupsOpen = false;
this.cancelTableGroupScroll();
}
},
additionalTableGroups() {
this.handleColumnDebounce();
},
visibleTableGroups() {
if (this._tableGroupScroll && !this.visibleTableGroups.includes(this._tableGroupScroll.key)) this.cancelTableGroupScroll();
this.handleColumnDebounce();
},
projectId: { projectId: {
handler(id) { handler(id) {
this.cancelTableGroupScroll();
this.tableGroupsOpen = false;
if (id > 0) { if (id > 0) {
this.getFlowData(); this.getFlowData();
this.handleColumnDebounce(); this.handleColumnDebounce();
@ -1949,6 +2047,47 @@ export default {
return $A.countDownFormat(this.nowTime, date) return $A.countDownFormat(this.nowTime, date)
}, },
setTableGroupVisible(group, visible) {
const selected = this.visibleTableGroups;
if (!visible && selected.length === 1 && selected.includes(group.key)) return;
const tableGroups = visible
? this.tableGroupOptions.filter(item => item.key === group.key || selected.includes(item.key)).map(item => item.key)
: selected.filter(key => key !== group.key);
this.toggleParameter({
project_id: this.projectId,
key: visible ? {tableGroups, [group.expandedKey]: true} : {tableGroups},
});
if (visible) this.scrollToTableGroup(group.key);
else this.cancelTableGroupScroll();
},
cancelTableGroupScroll() {
this._tableGroupScroll?.cancel?.();
this._tableGroupScroll = null;
},
scrollToTableGroup(key) {
this.cancelTableGroupScroll();
const projectId = this.projectId;
const request = {key};
this._tableGroupScroll = request;
const isActive = () => this._tableGroupScroll === request && this.projectId === projectId && this.tabTypeActive === 'table' && this.visibleTableGroups.includes(key);
this.$nextTick(() => {
if (!isActive()) return;
const scrollbar = this.$refs.projectTableScroll;
const container = scrollbar?.scrollElement();
const target = container?.querySelector(`[data-table-group="${key}"]`);
if (!target) return;
request.cancel = scrollToStableTarget({
container, target, inputRoot: scrollbar.$el, isActive,
render: async () => {
await this.handleTaskScroll({target: container});
await this.$nextTick();
},
});
});
},
tabTypeChange(type) { tabTypeChange(type) {
switch (type) { switch (type) {
case "column": case "column":

View File

@ -0,0 +1,56 @@
<template>
<Dropdown
:visible="open"
@on-visible-change="open = $event"
:trigger="touch ? 'click' : 'hover'"
:placement="placement"
transfer
transfer-class-name="project-table-groups-dropdown">
<div
@keydown.enter.prevent="open = true"
@keydown.space.prevent="open = true"
@keydown.esc="open = false">
<slot/>
</div>
<div slot="list" class="project-table-group-options" @click.stop @keydown.esc="open = false">
<Checkbox
v-for="group in groups"
:key="group.key"
class="ivu-dropdown-item"
:class="{'ivu-dropdown-item-disabled': selected.length === 1 && selected.includes(group.key)}"
:value="selected.includes(group.key)"
:disabled="selected.length === 1 && selected.includes(group.key)"
:title="selected.length === 1 && selected.includes(group.key) ? $L('至少保留一项') : ''"
@on-change="$emit('change', group, $event)">
{{$L(group.title)}}
</Checkbox>
</div>
</Dropdown>
</template>
<script>
import {projectTableGroups} from "../../../utils/projectTableGroups";
export default {
name: 'ProjectTableGroupDropdown',
props: {
value: {type: Boolean, default: false},
touch: {type: Boolean, default: false},
selected: {type: Array, required: true},
placement: {type: String, default: 'bottom-start'},
},
computed: {
groups() {
return projectTableGroups;
},
open: {
get() {
return this.value;
},
set(value) {
this.$emit('input', value);
},
},
},
};
</script>

View File

@ -0,0 +1,48 @@
export const projectTableGroups = [
{key: 'my', title: '我的任务', expandedKey: 'showMy', defaultVisible: true},
{key: 'help', title: '协助的任务', expandedKey: 'showHelp', defaultVisible: true},
{key: 'undone', title: '未完成任务', expandedKey: 'showUndone', defaultVisible: true},
{key: 'completed', title: '已完成任务', expandedKey: 'showCompleted'},
{key: 'dueThisWeek', title: '本周到期', expandedKey: 'showDueThisWeek', week: 0, kind: 'due'},
{key: 'dueNextWeek', title: '下周到期', expandedKey: 'showDueNextWeek', week: 1, kind: 'due'},
{key: 'plannedThisWeek', title: '本周排期', expandedKey: 'showPlannedThisWeek', week: 0, kind: 'planned'},
{key: 'plannedNextWeek', title: '下周排期', expandedKey: 'showPlannedNextWeek', week: 1, kind: 'planned'},
{key: 'overdue', title: '已逾期', expandedKey: 'showOverdue', kind: 'overdue'},
{key: 'unscheduled', title: '未排期', expandedKey: 'showUnscheduled', kind: 'unscheduled'},
];
export function normalizeProjectTableGroups(value) {
const selected = Array.isArray(value)
? projectTableGroups.filter(group => value.includes(group.key)).map(group => group.key)
: [];
return selected.length ? selected : projectTableGroups.filter(group => group.defaultVisible).map(group => group.key);
}
export function projectWeekRanges(now) {
// Do not depend on the current translation locale's first day of the week.
const monday = now.startOf('day').subtract((now.day() + 6) % 7, 'day');
return [0, 1].map(week => ({
start: monday.add(week * 7, 'day').valueOf(),
end: monday.add((week + 1) * 7, 'day').valueOf(),
}));
}
export function taskMatchesProjectWeek(task, range, kind, parseDate) {
if (!task.end_at) return false;
const end = parseDate(task.end_at).valueOf();
if (!Number.isFinite(end)) return false;
if (kind === 'due') return end >= range.start && end < range.end;
if (!task.start_at) return false;
const start = parseDate(task.start_at).valueOf();
return Number.isFinite(start) && start <= end && start < range.end && end >= range.start;
}
export function taskMatchesProjectGroup(task, group, ranges, now, parseDate) {
if (group.kind === 'unscheduled') {
return !task.complete_at && (!task.start_at || !task.end_at);
}
if (group.kind === 'overdue') {
return !task.complete_at && !!task.end_at && parseDate(task.end_at).valueOf() < now;
}
return taskMatchesProjectWeek(task, ranges[group.week], group.kind, parseDate);
}

View File

@ -0,0 +1,60 @@
// Lazy-rendered rows can move a target after the browser has started scrolling.
export function scrollToStableTarget({container, target, inputRoot = container, render, isActive}) {
let active = true;
let timer;
let deadline;
const startedAt = Date.now();
let stableSince = startedAt;
let previousTop = null;
let previousHeight = null;
const document = container.ownerDocument;
const desiredTop = () => Math.max(0, Math.min(
container.scrollHeight - container.clientHeight,
container.scrollTop + target.getBoundingClientRect().top - container.getBoundingClientRect().top - container.clientTop,
));
const move = (top, behavior) => container.scrollTo({top, left: container.scrollLeft, behavior});
const valid = () => active && container.isConnected && target.isConnected && isActive();
const finish = (stopMotion = false) => {
if (!active) return;
active = false;
clearTimeout(timer);
clearTimeout(deadline);
for (const event of ['wheel', 'touchstart', 'pointerdown']) inputRoot.removeEventListener(event, cancel, true);
document.removeEventListener('keydown', onKeyDown, true);
if (stopMotion && container.isConnected) move(container.scrollTop, 'instant');
};
const cancel = () => finish(true);
const onKeyDown = event => {
if (event.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(event.key)) cancel();
};
const tick = async () => {
if (!valid()) return cancel();
try {
await render();
if (!valid()) return cancel();
const now = Date.now();
const top = desiredTop();
const changed = previousTop === null || Math.abs(top - previousTop) > 1 || container.scrollHeight !== previousHeight;
const aligned = Math.abs(container.scrollTop - top) <= 1;
if (changed || !aligned) stableSince = now;
previousTop = top;
previousHeight = container.scrollHeight;
// Give the first smooth scroll time to finish; corrections must not start another animation.
if (!aligned && now - startedAt >= 600) move(top, 'instant');
if (aligned && now - startedAt >= 600 && now - stableSince >= 240) return finish();
timer = setTimeout(tick, 80);
} catch {
cancel();
}
};
if (!valid()) return cancel;
for (const event of ['wheel', 'touchstart', 'pointerdown']) inputRoot.addEventListener(event, cancel, {capture: true, passive: true});
document.addEventListener('keydown', onKeyDown, true);
deadline = setTimeout(cancel, 2500);
move(desiredTop(), 'smooth');
timer = setTimeout(tick, 80);
return cancel;
}

View File

@ -204,6 +204,28 @@
} }
} }
} }
.project-table-settings {
flex-shrink: 0;
margin-right: 8px;
color: $primary-text-color;
.project-table-settings-icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 30px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: inherit;
font-size: 18px;
cursor: pointer;
&:hover {
color: $primary-color;
}
}
}
.project-switch-button { .project-switch-button {
display: flex; display: flex;
align-items: center; align-items: center;
@ -653,6 +675,13 @@
border: 1px solid #F4F4F5; border: 1px solid #F4F4F5;
border-bottom: 0; border-bottom: 0;
overflow: hidden; overflow: hidden;
.project-table-empty {
padding: 16px;
color: $primary-text-color;
text-align: center;
background-color: #ffffff;
border-bottom: 1px solid #F4F4F5;
}
&.project-table-hide { &.project-table-hide {
.task-rows { .task-rows {
display: none; display: none;
@ -957,6 +986,24 @@
} }
} }
.project-table-groups-dropdown {
max-width: calc(100vw - 24px);
max-height: none;
overflow: visible;
.project-table-group-options {
display: flex;
flex-direction: column;
align-items: stretch;
overflow-y: auto;
.ivu-checkbox-wrapper {
margin-right: 0;
white-space: normal;
overflow-wrap: anywhere;
padding-right: 28px;
}
}
}
.project-panel-project-menu-dropdown { .project-panel-project-menu-dropdown {
max-height: calc(var(--window-height) - var(--status-bar-height) - var(--navigation-bar-height) - 120px); max-height: calc(var(--window-height) - var(--status-bar-height) - var(--navigation-bar-height) - 120px);
overflow-y: auto; overflow-y: auto;

View File

@ -0,0 +1,302 @@
// Isolated UI fixture: real panel markup, Dropdown and styles; no backend or dev server.
// PLAYWRIGHT_MODULE=/path/to/playwright BROWSER_PATH=/path/to/chrome node tests/project-table-groups.browser.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {parse} = require('@babel/parser');
const compiler = require('vue-template-compiler');
const sass = require('sass');
const {chromium} = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const root = path.resolve(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
const sfc = compiler.parseComponent(read('resources/assets/js/pages/manage/components/ProjectPanel.vue'));
const sourceTemplate = sfc.template.content.trim();
const templateAst = compiler.compile(sourceTemplate, {outputSourceRange: true}).ast;
function find(node, predicate) {
if (predicate(node)) return node;
for (const child of node.children || []) {
const match = find(child, predicate);
if (match) return match;
}
for (const branch of (node.ifConditions || []).slice(1)) {
const match = find(branch.block, predicate);
if (match) return match;
}
}
const switchNode = find(templateAst, node => node.attrsMap?.class === 'project-switch-button');
const settingsNode = find(templateAst, node => node.attrsMap?.class === 'project-table-settings');
const flowNode = find(templateAst, node => node.attrsMap?.class === 'project-select');
const tableNode = find(templateAst, node => node.attrsMap?.class === 'project-table');
const switchTemplate = sourceTemplate.slice(switchNode.start, switchNode.end);
const settingsTemplate = sourceTemplate.slice(settingsNode.start, settingsNode.end);
const flowTemplate = sourceTemplate.slice(flowNode.start, flowNode.end);
const tableTemplate = sourceTemplate.slice(tableNode.start, tableNode.end).replace('v-else-if=', 'v-if=');
const template = `<div class="project-panel"><div class="project-subbox"><div class="project-subtitle"></div><div class="project-switch">${flowTemplate}${settingsTemplate}${switchTemplate}</div></div>${tableTemplate}</div>`;
const scriptAst = parse(sfc.script.content, {sourceType: 'module'});
let script = sfc.script.content;
const imports = [];
for (const node of [...scriptAst.program.body].reverse()) {
if (node.type !== 'ImportDeclaration') continue;
if (!['projectTableGroups', 'scrollToStableTarget'].some(name => node.source.value.endsWith(name))) {
imports.push(...node.specifiers.map(specifier => specifier.local.name));
}
script = script.slice(0, node.start) + script.slice(node.end);
}
const declarations = imports.map(name => `const ${name} = ${['mapState', 'mapGetters'].includes(name) ? '()=>({})' : '{}'};`).join('\n');
const factory = declarations + read('resources/assets/js/utils/projectTableGroups.js').replace(/export /g, '') + read('resources/assets/js/utils/scrollToStableTarget.js').replace(/export /g, '') + script.replace('export default', 'return');
const dropdownComponent = compiler.parseComponent(read('resources/assets/js/pages/manage/components/ProjectTableGroupDropdown.vue'));
let dropdownScript = dropdownComponent.script.content;
for (const node of [...parse(dropdownScript, {sourceType: 'module'}).program.body].reverse()) {
if (node.type === 'ImportDeclaration') dropdownScript = dropdownScript.slice(0, node.start) + dropdownScript.slice(node.end);
}
const dropdownFactory = read('resources/assets/js/utils/projectTableGroups.js').replace(/export /g, '') + dropdownScript.replace('export default', 'return');
const css = sass.compileString(read('resources/assets/sass/var.scss') + read('resources/assets/sass/pages/components/project-panel.scss')).css;
const output = path.join(root, 'tests/playwright-results/project-table-groups');
fs.mkdirSync(output, {recursive: true});
(async () => {
const browser = await chromium.launch({executablePath: process.env.BROWSER_PATH, headless: true, args: ['--no-sandbox']});
try {
for (const mobile of [false, true]) {
const context = await browser.newContext({viewport: mobile ? {width: 390, height: 844} : {width: 1440, height: 900}, hasTouch: mobile});
const page = await context.newPage();
const errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.setContent('<html><body><div id="app"></div></body></html>');
await page.addStyleTag({path: path.join(root, 'node_modules/view-design-hi/dist/styles/iview.css')});
await page.addStyleTag({content: css + '\nbody{font-family:Arial,sans-serif}.project-panel{height:100vh}.project-subbox{padding-top:24px!important}.project-table{overflow:auto}'});
const font = fs.readFileSync(path.join(root, 'resources/assets/statics/public/css/fonts/taskfont/iconfont.woff2')).toString('base64');
await page.addStyleTag({content: `@font-face{font-family:taskfont;src:url(data:font/woff2;base64,${font}) format('woff2')}.taskfont{font-family:taskfont!important;font-style:normal}`});
const iconFont = fs.readFileSync(path.join(root, 'node_modules/view-design-hi/dist/styles/fonts/ionicons.woff2')).toString('base64');
await page.addStyleTag({content: `@font-face{font-family:Ionicons;src:url(data:font/woff2;base64,${iconFont}) format('woff2')}`});
await page.addScriptTag({path: path.join(root, 'node_modules/vue/dist/vue.js')});
await page.addScriptTag({path: path.join(root, 'node_modules/dayjs/dayjs.min.js')});
await page.addScriptTag({path: path.join(root, 'node_modules/view-design-hi/dist/iview.min.js')});
await page.evaluate(({factory, template, mobile, dropdownFactory, dropdownTemplate}) => {
Vue.use(iview);
Vue.prototype.$L = value => value;
Vue.prototype.$isEEUIApp = false;
const helpers = {
dayjs, daytz: value => value === undefined ? dayjs() : dayjs.unix(value),
leftExists: (value, prefix) => String(value || '').startsWith(prefix),
sortDay: (a, b) => dayjs(a).valueOf() - dayjs(b).valueOf(),
sortFloat: (a, b) => a - b,
};
const options = new Function('$A', factory)(helpers);
const dropdown = {...new Function(dropdownFactory)(), template: dropdownTemplate};
const computed = {};
for (const key of ['tableGroupOptions', 'visibleTableGroups', 'tableWeekRanges', 'additionalTableGroups', 'tabTypeActive', 'tabTypeStyle', 'panelTask']) computed[key] = options.computed[key];
window.fixture = new Vue({
el: '#app', template,
data: {
...options.data(), windowTouch: mobile, projectId: 12,
flowTitle: '全部', flowData: [{value: 'all', label: '全部'}],
projectData: {task_num: 0, cacheParameter: {menuType: 'column', showMy: true, showHelp: true, showUndone: true, showCompleted: false}},
myList: [], helpList: [], unList: [], completedList: [], parentTask: [], isDepartmentReadonly: false,
},
computed,
watch: {tabTypeActive: options.watch.tabTypeActive},
components: {
ProjectTableGroupDropdown: dropdown,
Scrollbar: {template: '<div><slot/></div>', methods: {scrollElement() {return this.$el;}}},
TaskRow: {props: ['list'], template: '<div class="task-rows"><div v-for="task in list" :key="task.id">{{task.name}}</div></div>'},
},
methods: {
setTableGroupVisible: options.methods.setTableGroupVisible,
scrollToTableGroup: options.methods.scrollToTableGroup,
cancelTableGroupScroll: options.methods.cancelTableGroupScroll,
tabTypeChange: options.methods.tabTypeChange,
flowChange: options.methods.flowChange,
handleColumnDebounce() {},
toggleParameter(update) {
const patch = typeof update === 'string' ? {[update]: !this.projectData.cacheParameter[update]} : typeof update.key === 'string' ? {[update.key]: update.value} : update.key;
this.projectData.cacheParameter = {...this.projectData.cacheParameter, ...patch};
},
transforTasks: list => list,
addTaskOpen() {}, handleTaskScroll() {}, onSort() {},
},
});
}, {factory, template, mobile, dropdownFactory, dropdownTemplate: dropdownComponent.template.content});
const flow = page.locator('.project-flow');
const flowDropdown = page.locator('.project-panel-flow-cascader:visible');
await flow.hover();
if (mobile) {
assert.equal(await flowDropdown.count(), 0);
await flow.tap();
}
await flowDropdown.waitFor({state: 'visible'});
const surfaceStyle = element => {
const style = getComputedStyle(element);
return {background: style.backgroundColor, radius: style.borderRadius, shadow: style.boxShadow};
};
const flowSurface = await flowDropdown.evaluate(surfaceStyle);
if (!mobile) {
await flow.click();
await flowDropdown.hover();
await page.waitForTimeout(250);
assert.equal(await flowDropdown.count(), 1);
await page.mouse.move(1, 1);
await flowDropdown.waitFor({state: 'hidden'});
await flow.hover();
await flowDropdown.waitFor({state: 'visible'});
}
await flowDropdown.locator('.ivu-cascader-menu-item:visible').getByText('全部', {exact: true}).click();
await flowDropdown.waitFor({state: 'hidden'});
assert.equal(await page.evaluate(() => fixture.flowInfo.value), 'all');
await page.evaluate(() => { fixture.flowInfo = {}; });
const icon = page.locator('.project-switch-button > div').nth(2);
const settings = page.locator('.project-table-settings-icon');
const dropdown = page.locator('.project-table-groups-dropdown:visible');
await icon.hover();
await page.waitForTimeout(250);
assert.equal(await dropdown.count(), 0);
assert.equal(await settings.count(), 0);
await icon.click();
await settings.waitFor({state: 'visible'});
await icon.hover();
await icon.click();
await page.waitForTimeout(250);
assert.equal(await dropdown.count(), 0);
if (mobile) await settings.tap();
else await settings.hover();
await dropdown.waitFor({state: 'visible'});
assert.deepEqual(await dropdown.evaluate(surfaceStyle), flowSurface);
assert.equal(await dropdown.locator('.ivu-poptip-arrow, .ivu-poptip-title').count(), 0);
const settingsBounds = await settings.boundingBox();
assert.equal(await page.locator('.project-switch-button').count(), 1, JSON.stringify({errors, html: await page.locator('.project-switch').innerHTML()}));
const switchBounds = await page.locator('.project-switch-button').boundingBox();
assert.ok(settingsBounds.x + settingsBounds.width <= switchBounds.x);
const boxes = dropdown.locator('input[type=checkbox]');
assert.equal(await boxes.count(), 10);
assert.equal(await dropdown.locator('input:checked').count(), 3);
await dropdown.getByText('本周到期', {exact: true}).click();
assert.equal(await boxes.nth(4).isChecked(), true);
assert.equal(await page.locator('.project-table-body').count(), 4);
assert.equal(await page.locator('.project-table-empty').count(), 4);
for (const label of ['本周排期', '下周排期', '已逾期', '未排期']) {
await dropdown.getByText(label, {exact: true}).click();
assert.equal(await page.locator('.row-h1').getByText(label, {exact: true}).count(), 1);
await dropdown.getByText(label, {exact: true}).click();
assert.equal(await page.locator('.row-h1').getByText(label, {exact: true}).count(), 0);
}
for (const label of ['我的任务', '协助的任务', '未完成任务']) await dropdown.getByText(label, {exact: true}).click();
assert.equal(await boxes.nth(4).isDisabled(), true);
assert.equal(await page.locator('.project-table-body').count(), 1);
await page.waitForFunction(() => fixture.visibleTableGroups.length === 1);
const bounds = await dropdown.boundingBox();
const viewport = page.viewportSize();
assert.ok(bounds.x >= 0 && bounds.y >= 0 && bounds.x + bounds.width <= viewport.width + 1 && bounds.y + bounds.height <= viewport.height);
await page.screenshot({path: path.join(output, mobile ? 'mobile.png' : 'desktop.png'), animations: 'disabled'});
if (mobile) await icon.tap();
else await icon.hover();
await dropdown.waitFor({state: 'hidden'});
assert.equal(await dropdown.count(), 0);
assert.equal(await page.evaluate(() => fixture.tableGroupsOpen), false);
if (mobile) await settings.tap();
else await settings.hover();
await dropdown.waitFor({state: 'visible'});
assert.equal(await dropdown.locator('input:checked').count(), 1);
await dropdown.getByText('已完成任务', {exact: true}).click();
assert.equal(await page.evaluate(() => fixture.projectData.cacheParameter.showCompleted), true);
await page.locator('.row-title .taskfont').first().click();
assert.equal(await page.evaluate(() => fixture.projectData.cacheParameter.showCompleted), false);
await page.evaluate(async mobile => {
fixture.myList = Array.from({length: 80}, (_, id) => ({id, name: `Task ${id + 1}`}));
fixture.projectData.cacheParameter = {...fixture.projectData.cacheParameter, tableGroups: ['my'], showMy: true};
await fixture.$nextTick();
const container = fixture.$refs.projectTableScroll.scrollElement();
container.scrollTop = 0;
container.scrollLeft = mobile ? 48 : 0;
window.scrollTest = {left: container.scrollLeft, pageTop: window.scrollY, calls: []};
const scrollTo = container.scrollTo.bind(container);
container.scrollTo = options => {scrollTest.calls.push(options); scrollTo(options);};
}, mobile);
if (mobile) await settings.tap();
else await settings.hover();
await dropdown.waitFor({state: 'visible'});
for (const [label, key] of [['未排期', 'unscheduled'], ['已完成任务', 'completed']]) {
if (!mobile) await dropdown.hover();
const before = await page.evaluate(() => scrollTest.calls.length);
await dropdown.getByText(label, {exact: true}).click();
await page.waitForFunction(key => {
const container = fixture.$refs.projectTableScroll.scrollElement();
const target = container.querySelector(`[data-table-group="${key}"]`);
const bounds = container.getBoundingClientRect();
const heading = target?.querySelector('.row-title').getBoundingClientRect();
return container.scrollTop > 0 && heading && heading.top >= bounds.top - 1 && heading.bottom <= bounds.top + container.clientHeight + 1;
}, key);
await page.waitForTimeout(1000);
assert.ok(await page.evaluate(before => scrollTest.calls.length >= before + 1, before));
assert.equal(await dropdown.count(), 1, JSON.stringify(await page.evaluate(() => ({open: fixture.tableGroupsOpen, pageTop: window.scrollY, before: scrollTest, settings: document.querySelector('.project-table-settings').getBoundingClientRect().toJSON(), container: fixture.$refs.projectTableScroll.scrollElement().getBoundingClientRect().toJSON()}))));
assert.ok(await page.evaluate(() => {
const container = fixture.$refs.projectTableScroll.scrollElement();
return container.scrollLeft === scrollTest.left && window.scrollY === scrollTest.pageTop;
}));
if (key === 'unscheduled') await page.screenshot({path: path.join(output, mobile ? 'mobile-scroll.png' : 'desktop-scroll.png'), animations: 'disabled'});
const settled = await page.evaluate(() => scrollTest.calls.length);
await dropdown.getByText(label, {exact: true}).click();
await page.waitForTimeout(200);
assert.equal(await page.evaluate(() => scrollTest.calls.length), settled);
}
await page.evaluate(async () => {
fixture.projectData.cacheParameter = {...fixture.projectData.cacheParameter, tableGroups: ['my', 'unscheduled'], showUnscheduled: true};
await fixture.$nextTick();
const container = fixture.$refs.projectTableScroll.scrollElement();
const spacer = document.createElement('div');
spacer.style.height = '1000px';
container.appendChild(spacer);
const rows = container.querySelector('[data-table-group="my"] .task-rows');
window.lazyScrollTest = {renders: 0};
fixture.handleTaskScroll = async () => {
lazyScrollTest.renders++;
if (lazyScrollTest.renders === 3) rows.style.paddingTop = '350px';
if (lazyScrollTest.renders === 9) rows.style.paddingTop = '180px';
await fixture.$nextTick();
};
container.scrollTop = 0;
fixture.scrollToTableGroup('unscheduled');
});
await page.waitForFunction(() => {
const container = fixture.$refs.projectTableScroll.scrollElement();
const target = container.querySelector('[data-table-group="unscheduled"]');
return lazyScrollTest.renders >= 10 && Math.abs(target.getBoundingClientRect().top - container.getBoundingClientRect().top - container.clientTop) <= 1;
});
await page.waitForTimeout(350);
assert.ok(await page.evaluate(() => {
const container = fixture.$refs.projectTableScroll.scrollElement();
return Math.abs(container.querySelector('[data-table-group="unscheduled"]').getBoundingClientRect().top - container.getBoundingClientRect().top - container.clientTop) <= 1;
}));
await page.screenshot({path: path.join(output, mobile ? 'mobile-corrected.png' : 'desktop-corrected.png'), animations: 'disabled'});
await page.evaluate(() => {
fixture.handleTaskScroll = async () => fixture.$nextTick();
fixture.$refs.projectTableScroll.scrollElement().scrollTop = 0;
fixture.scrollToTableGroup('unscheduled');
});
await page.waitForTimeout(100);
if (mobile) {
await page.locator('.project-table').dispatchEvent('touchstart');
} else {
const bounds = await page.locator('.project-table').boundingBox();
await page.mouse.move(bounds.x + 10, bounds.y + bounds.height / 2);
await page.mouse.wheel(0, -100);
}
await page.waitForTimeout(200);
const interrupted = await page.evaluate(() => ({calls: scrollTest.calls.length, top: fixture.$refs.projectTableScroll.scrollElement().scrollTop}));
await page.waitForTimeout(800);
assert.deepEqual(await page.evaluate(() => ({calls: scrollTest.calls.length, top: fixture.$refs.projectTableScroll.scrollElement().scrollTop})), interrupted);
for (const mode of ['column', 'gantt']) {
await page.evaluate(mode => fixture.tabTypeChange(mode), mode);
await icon.hover();
await page.waitForTimeout(250);
assert.equal(await dropdown.count(), 0);
assert.equal(await settings.count(), 0);
assert.equal(await page.evaluate(() => fixture.tableGroupsOpen), false);
}
assert.deepEqual(errors, []);
console.log(`${mobile ? 'Mobile' : 'Desktop'}: Cascader trigger and selection, group selection, relayout correction, manual cancellation, unchanged horizontal/page position and viewport bounds passed`);
await context.close();
}
} finally {
await browser.close();
}
})().catch(error => {console.error(error); process.exitCode = 1;});

View File

@ -0,0 +1,350 @@
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import test from 'node:test';
import dayjs from 'dayjs';
import compiler from 'vue-template-compiler';
import {parse} from '@babel/parser';
const read = path => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
const helpers = await import(`data:text/javascript;base64,${Buffer.from(read('resources/assets/js/utils/projectTableGroups.js')).toString('base64')}`);
const {projectTableGroups, normalizeProjectTableGroups, projectWeekRanges, taskMatchesProjectWeek, taskMatchesProjectGroup} = helpers;
const component = compiler.parseComponent(read('resources/assets/js/pages/manage/components/ProjectPanel.vue'));
const ast = parse(component.script.content, {sourceType: 'module'});
const stableScrolls = [];
const bindings = {
...helpers,
scrollToStableTarget: options => {stableScrolls.push(options); return () => {};},
mapState: () => ({}),
mapGetters: () => ({}),
$A: {
dayjs,
daytz: seconds => dayjs.unix(seconds),
sortFloat: (a, b) => (parseFloat(a) || 0) - (parseFloat(b) || 0),
sortDay: (a, b) => dayjs(a).valueOf() - dayjs(b).valueOf(),
leftExists: (value, prefix) => String(value || '').startsWith(prefix),
strExists: (value, search) => String(value).includes(search),
},
};
let script = component.script.content;
// Load the real component's options without mounting unrelated application services.
for (const node of [...ast.program.body].reverse()) {
if (node.type !== 'ImportDeclaration') continue;
for (const specifier of node.specifiers) bindings[specifier.local.name] ??= {};
script = script.slice(0, node.start) + script.slice(node.end);
}
const panel = new Function(...Object.keys(bindings), script.replace('export default', 'return'))(...Object.values(bindings));
const ranges = projectWeekRanges(dayjs('2026-09-09 12:00:00'));
const matches = (task, week = 0, kind = 'due') => taskMatchesProjectWeek(task, ranges[week], kind, dayjs);
const defaults = ['my', 'help', 'undone'];
test('old or invalid caches use the three default groups; valid choices stay isolated', () => {
for (const value of [undefined, null, {}, [], ['unknown']]) {
assert.deepEqual(normalizeProjectTableGroups(value), defaults);
}
assert.deepEqual(normalizeProjectTableGroups(['dueNextWeek', 'dueNextWeek', 'unknown']), ['dueNextWeek']);
assert.deepEqual(normalizeProjectTableGroups(['completed']), ['completed']);
assert.deepEqual(normalizeProjectTableGroups(['plannedThisWeek', 'plannedNextWeek']), ['plannedThisWeek', 'plannedNextWeek']);
assert.equal(projectTableGroups.find(group => group.key === 'plannedThisWeek').title, '本周排期');
assert.equal(projectTableGroups.find(group => group.key === 'plannedNextWeek').title, '下周排期');
assert.deepEqual(normalizeProjectTableGroups(['overdue', 'unscheduled']), ['overdue', 'unscheduled']);
const firstProject = normalizeProjectTableGroups();
firstProject.push('completed');
assert.deepEqual(normalizeProjectTableGroups(), defaults);
});
test('weeks start on Monday regardless of locale, including Sundays and year changes', () => {
for (const date of ['2026-09-07', '2026-09-09', '2026-09-13']) {
assert.deepEqual(projectWeekRanges(dayjs(date)), ranges);
}
assert.equal(dayjs(ranges[0].start).format('YYYY-MM-DD HH:mm'), '2026-09-07 00:00');
assert.equal(ranges[0].end, ranges[1].start);
const newYear = projectWeekRanges(dayjs('2027-01-01'));
assert.equal(dayjs(newYear[0].start).format('YYYY-MM-DD'), '2026-12-28');
assert.equal(dayjs(newYear[1].start).format('YYYY-MM-DD'), '2027-01-04');
});
test('due dates include Monday midnight but exclude the following Monday', () => {
assert.equal(matches({end_at: '2026-09-06 23:59:59'}), false);
assert.equal(matches({end_at: '2026-09-07 00:00:00'}), true);
assert.equal(matches({end_at: '2026-09-13 23:59:59'}), true);
assert.equal(matches({end_at: '2026-09-14 00:00:00'}), false);
assert.equal(matches({end_at: '2026-09-14 00:00:00'}, 1), true);
assert.equal(matches({end_at: '2026-09-21 00:00:00'}, 1), false);
});
test('planned tasks overlap either week and may appear in both', () => {
const task = {start_at: '2026-09-05', end_at: '2026-09-18'};
assert.equal(matches(task, 0, 'planned'), true);
assert.equal(matches(task, 1, 'planned'), true);
assert.equal(matches({start_at: '2026-09-06', end_at: '2026-09-07'}, 0, 'planned'), true);
assert.equal(matches({start_at: '2026-09-14', end_at: '2026-09-15'}, 0, 'planned'), false);
assert.equal(matches({start_at: '2026-09-07', end_at: '2026-09-07'}, 0, 'planned'), true);
});
test('missing, invalid or reversed plans do not enter planned groups', () => {
for (const task of [
{}, {start_at: '2026-09-08'}, {end_at: '2026-09-08'},
{start_at: 'invalid', end_at: '2026-09-08'},
{start_at: '2026-09-08', end_at: 'invalid'},
{start_at: '2026-09-09', end_at: '2026-09-08'},
]) assert.equal(matches(task, 0, 'planned'), false);
assert.equal(matches({end_at: 'invalid'}), false);
assert.equal(matches({end_at: '2026-09-08'}), true);
});
test('selection cannot remove the last group and reselecting expands only that group', () => {
const updates = [];
const scrolls = [];
const context = {
projectId: 12,
tableGroupOptions: projectTableGroups,
visibleTableGroups: ['my'],
toggleParameter: update => updates.push(update),
scrollToTableGroup: key => scrolls.push(key),
cancelTableGroupScroll() {},
};
panel.methods.setTableGroupVisible.call(context, projectTableGroups[0], false);
assert.equal(updates.length, 0);
assert.equal(scrolls.length, 0);
panel.methods.setTableGroupVisible.call(context, projectTableGroups[4], true);
assert.deepEqual(updates.pop(), {
project_id: 12,
key: {tableGroups: ['my', 'dueThisWeek'], showDueThisWeek: true},
});
context.visibleTableGroups = ['my', 'dueThisWeek'];
panel.methods.setTableGroupVisible.call(context, projectTableGroups[0], false);
assert.deepEqual(updates.pop(), {project_id: 12, key: {tableGroups: ['dueThisWeek']}});
assert.deepEqual(scrolls, ['dueThisWeek']);
});
test('existing Vuex parameter action persists group choices per project without changing view or collapse state', () => {
const stored = {};
const api = {
syncDispatch() {},
isJson: value => !!value && typeof value === 'object' && !Array.isArray(value),
IDBSave: (key, value) => { stored[key] = structuredClone(value); },
};
const method = (file, key) => {
const source = read(file);
const declaration = parse(source, {sourceType: 'module'}).program.body.find(node => node.type === 'ExportDefaultDeclaration').declaration;
const property = declaration.properties.find(node => (node.key.name || node.key.value) === key);
return new Function('$A', `return ({${source.slice(property.start, property.end)}})[${JSON.stringify(key)}]`)(api);
};
const action = method('resources/assets/js/store/actions.js', 'toggleProjectParameter');
const mutation = method('resources/assets/js/store/mutations.js', 'project/parameter/splice');
const getter = method('resources/assets/js/store/getters.js', 'projectData');
const state = {
projectId: 12,
cacheProjects: [{id: 12}, {id: 13}],
cacheProjectParameter: [
{project_id: 12, menuType: 'table', showMy: false, showHelp: true},
{project_id: 13, menuType: 'column', tableGroups: ['completed']},
],
};
const context = {
projectId: 12, visibleTableGroups: defaults, tableGroupOptions: projectTableGroups,
scrollToTableGroup() {},
toggleParameter: update => action({state, commit: (type, payload) => {
assert.equal(type, 'project/parameter/splice');
mutation(state, payload);
}}, update),
};
panel.methods.setTableGroupVisible.call(context, projectTableGroups[4], true);
state.cacheProjectParameter = stored.cacheProjectParameter;
const restored = getter(state).cacheParameter;
assert.deepEqual(restored.tableGroups, ['my', 'help', 'undone', 'dueThisWeek']);
assert.equal(restored.showMy, false);
assert.equal(restored.showDueThisWeek, true);
assert.equal(restored.menuType, 'table');
state.projectId = 13;
assert.deepEqual(getter(state).cacheParameter.tableGroups, ['completed']);
assert.equal(getter(state).cacheParameter.menuType, 'column');
});
test('new group scrolling waits for DOM updates and replaces or skips stale requests', () => {
const callbacks = [];
stableScrolls.length = 0;
const container = {
scrollTop: 100, scrollLeft: 48, clientTop: 1,
getBoundingClientRect: () => ({top: 20}),
querySelector: selector => selector === '[data-table-group="my"]' ? {getBoundingClientRect: () => ({top: 221})} : null,
};
const context = {
projectId: 12, tabTypeActive: 'table', visibleTableGroups: ['my'],
$refs: {projectTableScroll: {scrollElement: () => container}},
$nextTick: callback => callbacks.push(callback),
};
context.cancelTableGroupScroll = panel.methods.cancelTableGroupScroll.bind(context);
const schedule = () => panel.methods.scrollToTableGroup.call(context, 'my');
schedule();
assert.equal(stableScrolls.length, 0);
callbacks.pop()();
const initial = stableScrolls.pop();
assert.equal(initial.container, container);
assert.equal(initial.isActive(), true);
schedule();
assert.equal(initial.isActive(), false);
schedule();
callbacks.shift()();
assert.equal(stableScrolls.length, 0);
callbacks.pop()();
assert.equal(stableScrolls.length, 1);
stableScrolls.length = 0;
for (const invalidate of [
() => { context.projectId = 13; },
() => { context.tabTypeActive = 'column'; },
() => { context.visibleTableGroups = []; },
() => { context.$refs = {}; },
]) {
context.projectId = 12;
context.tabTypeActive = 'table';
context.visibleTableGroups = ['my'];
schedule();
invalidate();
callbacks.pop()();
assert.equal(stableScrolls.length, 0);
}
});
function weeklyContext() {
const task = {
id: 1, parent_id: 0, name: 'alpha', desc: '', start_at: '2026-09-07', end_at: '2026-09-10',
p_level: 1, task_tag: [{name: 'tag'}], task_user: [{userid: 2, owner: 1}], flow_item_id: 3,
};
const context = {
tableGroupOptions: projectTableGroups,
visibleTableGroups: ['dueThisWeek', 'plannedThisWeek'],
tableWeekRanges: ranges,
nowTime: dayjs('2026-09-09 12:00:00').unix(),
allTask: [task, {...task, id: 2, p_level: 2, end_at: '2026-09-08'},
{...task, id: 3, parent_id: 1}, {...task, id: 4, complete_at: '2026-09-08'}],
searchText: '', flowInfo: {}, sortType: 'desc', sortField: 'end_at',
projectData: {cacheParameter: {completedTask: false}},
};
context.parentTask = panel.computed.parentTask.call(context);
context.panelTask = list => panel.computed.panelTask.call(context).call(context, list);
return context;
}
test('weekly groups reuse completion, search, owner, tag and workflow filters', () => {
const context = weeklyContext();
const ids = () => panel.computed.additionalTableGroups.call(context)[0].tasks.map(task => task.id);
assert.deepEqual(ids(), [2, 1]);
context.projectData.cacheParameter.completedTask = true;
assert.deepEqual(ids(), [2, 1, 4]);
context.searchText = 'alpha';
assert.equal(ids().length, 3);
context.searchText = '2';
assert.deepEqual(ids(), [2]);
context.searchText = 'not found';
assert.deepEqual(ids(), []);
context.searchText = '';
for (const flowInfo of [{value: 'tag:tag', tag_name: 'missing'}, {value: 'user:99', userid: 99}, {value: 4}, {value: -1}]) {
context.flowInfo = flowInfo;
assert.deepEqual(ids(), []);
}
for (const flowInfo of [{value: 'tag:tag', tag_name: 'tag'}, {value: 'user:2', userid: 2}, {value: 3}]) {
context.flowInfo = flowInfo;
assert.equal(ids().length, 3);
}
});
test('weekly groups follow priority and due-date sorting in both directions', () => {
const context = weeklyContext();
const ids = () => panel.computed.additionalTableGroups.call(context)[0].tasks.map(task => task.id);
assert.deepEqual(ids(), [2, 1]);
context.sortType = 'asc';
assert.deepEqual(ids(), [1, 2]);
context.sortField = 'level';
assert.deepEqual(ids(), [2, 1]);
context.sortType = 'desc';
assert.deepEqual(ids(), [1, 2]);
});
test('selected empty weekly groups remain present; deselected groups disappear', () => {
const context = weeklyContext();
context.searchText = 'not found';
const groups = panel.computed.additionalTableGroups.call(context);
assert.equal(groups.length, 2);
assert.ok(groups.every(group => group.tasks.length === 0));
context.visibleTableGroups = ['my'];
assert.deepEqual(panel.computed.additionalTableGroups.call(context), []);
});
test('overdue requires an unfinished task strictly past its due time', () => {
const group = projectTableGroups.find(group => group.key === 'overdue');
const now = dayjs('2026-09-09 12:00:00').valueOf();
const match = task => taskMatchesProjectGroup(task, group, ranges, now, dayjs);
assert.equal(match({end_at: '2026-09-09 11:59:59'}), true);
assert.equal(match({end_at: '2026-09-09 12:00:00'}), false);
assert.equal(match({end_at: '2026-09-09 12:00:01'}), false);
assert.equal(match({end_at: '2026-09-08', complete_at: '2026-09-08'}), false);
assert.equal(match({}), false);
assert.equal(match({end_at: 'invalid'}), false);
});
test('unscheduled includes either missing endpoint but never completed tasks', () => {
const group = projectTableGroups.find(group => group.key === 'unscheduled');
const match = task => taskMatchesProjectGroup(task, group, ranges, 0, dayjs);
assert.equal(match({}), true);
assert.equal(match({start_at: '2026-09-08'}), true);
assert.equal(match({end_at: '2026-09-08'}), true);
assert.equal(match({start_at: '2026-09-08', end_at: '2026-09-09'}), false);
assert.equal(match({complete_at: '2026-09-08'}), false);
});
test('attention groups exclude completed tasks even when enabled and retain existing filters', () => {
const context = weeklyContext();
context.visibleTableGroups = ['overdue', 'unscheduled'];
context.projectData.cacheParameter.completedTask = true;
context.parentTask.push({...context.parentTask[0], id: 5, start_at: null},
{...context.parentTask[0], id: 6, end_at: null},
{...context.parentTask[0], id: 7, end_at: null, complete_at: '2026-09-08'});
const groups = () => panel.computed.additionalTableGroups.call(context);
assert.deepEqual(groups().map(group => group.tasks.map(task => task.id)), [[2], [5, 6]]);
context.nowTime = dayjs('2026-09-11').unix();
assert.deepEqual(groups()[0].tasks.map(task => task.id), [2, 1, 5]);
context.searchText = '6';
assert.deepEqual(groups().map(group => group.tasks.map(task => task.id)), [[], [6]]);
context.searchText = '';
context.flowInfo = {value: 'tag:missing', tag_name: 'missing'};
assert.ok(groups().every(group => group.tasks.length === 0));
});
test('Vue template compiles and legacy groups use selection rather than task counts', () => {
const compiled = compiler.compile(component.template.content);
assert.deepEqual(compiled.errors, []);
for (const key of ['my', 'help', 'undone', 'completed']) {
assert.ok(component.template.content.includes(`v-if="visibleTableGroups.includes('${key}')"`));
}
assert.ok(!component.template.content.includes('v-if="projectData.task_num > 0"'));
assert.ok(!component.template.content.includes('v-if="helpList.length"'));
});
test('settings Dropdown uses boolean visibility and is the only group settings entry', () => {
const sfc = compiler.parseComponent(read('resources/assets/js/pages/manage/components/ProjectTableGroupDropdown.vue'));
assert.deepEqual(compiler.compile(sfc.template.content).errors, []);
const menu = compiler.compile(sfc.template.content).ast;
assert.equal(menu.tag, 'Dropdown');
assert.equal(menu.attrsMap[':visible'], 'open');
assert.equal(menu.attrsMap['@on-visible-change'], 'open = $event');
assert.equal(menu.attrsMap[':trigger'], "touch ? 'click' : 'hover'");
let script = sfc.script.content;
for (const node of [...parse(script, {sourceType: 'module'}).program.body].reverse()) {
if (node.type === 'ImportDeclaration') script = script.slice(0, node.start) + script.slice(node.end);
}
const options = new Function('projectTableGroups', script.replace('export default', 'return'))(projectTableGroups);
const emitted = [];
const context = {value: false, $emit: (...args) => emitted.push(args)};
assert.equal(options.computed.open.get.call(context), false);
options.computed.open.set.call(context, true);
assert.deepEqual(emitted.pop(), ['input', true]);
context.value = true;
assert.equal(options.computed.open.get.call(context), true);
options.computed.open.set.call(context, false);
assert.deepEqual(emitted.pop(), ['input', false]);
assert.equal((component.template.content.match(/<ProjectTableGroupDropdown\b/g) || []).length, 1);
const panelContext = {tableGroupsOpen: true, cancelTableGroupScroll() {}};
panel.watch.tabTypeActive.call(panelContext, 'column');
assert.equal(panelContext.tableGroupsOpen, false);
});

View File

@ -0,0 +1,110 @@
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import test from 'node:test';
const source = readFileSync(new URL('../resources/assets/js/utils/scrollToStableTarget.js', import.meta.url), 'utf8');
function fixture(render = () => {}) {
let now = 0;
let sequence = 0;
const timers = new Map();
const events = () => ({
listeners: new Map(),
addEventListener(type, fn) {this.listeners.set(type, fn);},
removeEventListener(type) {this.listeners.delete(type);},
dispatch(type, event = {}) {this.listeners.get(type)?.(event);},
});
const document = events();
const root = events();
const scrolls = [];
const container = {
ownerDocument: document, isConnected: true, scrollTop: 0, scrollLeft: 48,
clientTop: 0, clientHeight: 400, scrollHeight: 4000,
getBoundingClientRect: () => ({top: 20}),
scrollTo(options) {scrolls.push({...options, at: now}); this.scrollTop = options.top;},
};
const target = {isConnected: true, top: 1000, getBoundingClientRect: () => ({top: 20 + target.top - container.scrollTop})};
const start = new Function('setTimeout', 'clearTimeout', 'Date', `${source.replace('export ', '')}; return scrollToStableTarget;`)(
(fn, delay) => {const id = ++sequence; timers.set(id, {fn, at: now + delay}); return id;},
id => timers.delete(id), {now: () => now},
);
let live = true;
const cancel = start({container, target, inputRoot: root, render: () => render({now, container, target}), isActive: () => live});
const advance = async until => {
while (timers.size) {
const [id, timer] = [...timers].sort((a, b) => a[1].at - b[1].at)[0];
if (timer.at > until) break;
now = timer.at;
timers.delete(id);
timer.fn();
for (let i = 0; i < 5; i++) await Promise.resolve();
}
now = until;
};
return {container, target, root, document, scrolls, timers, advance, cancel, invalidate: () => {live = false;}};
}
test('corrects growing and shrinking lazy rows, then stops after stable alignment', async () => {
const f = fixture(({now, target, container}) => {
target.top = now < 320 ? 1000 : now < 800 ? 1600 : 1200;
container.scrollHeight = target.top + 3000;
});
await f.advance(3000);
assert.equal(f.scrolls[0].behavior, 'smooth');
assert.deepEqual(f.scrolls.slice(1).map(({top, behavior}) => [top, behavior]), [[1600, 'instant'], [1200, 'instant']]);
assert.equal(f.container.scrollTop, 1200);
assert.ok(f.scrolls.every(scroll => scroll.left === 48));
assert.equal(f.timers.size, 0);
assert.equal(f.root.listeners.size + f.document.listeners.size, 0);
});
test('clamps bottom targets and finishes without fighting the scroll limit', async () => {
const f = fixture(({target}) => {target.top = 3950;});
await f.advance(3000);
assert.equal(f.container.scrollTop, 3600);
assert.equal(f.timers.size, 0);
});
test('wheel, touch, scrollbar pointer and navigation keys cancel without further corrections', async () => {
for (const event of ['wheel', 'touchstart', 'pointerdown', 'keydown']) {
const f = fixture(({target}) => {target.top += 100;});
await f.advance(160);
if (event === 'keydown') f.document.dispatch(event, {key: 'PageDown'});
else f.root.dispatch(event);
const count = f.scrolls.length;
assert.equal(f.scrolls.at(-1).behavior, 'instant');
f.container.scrollTop = 75;
await f.advance(4000);
assert.equal(f.scrolls.length, count);
assert.equal(f.container.scrollTop, 75);
assert.equal(f.root.listeners.size + f.document.listeners.size, 0);
}
});
test('hard deadline stops continuous relayout and also a pending render promise', async () => {
for (const render of [({target}) => {target.top += 10;}, () => new Promise(() => {})]) {
const f = fixture(render);
await f.advance(4000);
assert.equal(f.scrolls.at(-1).at, 2500);
assert.equal(f.timers.size, 0);
assert.equal(f.root.listeners.size + f.document.listeners.size, 0);
}
});
test('replacement, removal and explicit cancellation clean up the old request', async () => {
for (const invalidate of [f => f.invalidate(), f => {f.target.isConnected = false;}, f => f.cancel()]) {
const f = fixture();
invalidate(f);
await f.advance(3000);
assert.equal(f.timers.size, 0);
assert.equal(f.root.listeners.size + f.document.listeners.size, 0);
assert.equal(f.scrolls.length, 2);
}
});
test('render errors stop safely and do not leave timers or listeners', async () => {
const f = fixture(() => {throw new Error('unmounted');});
await f.advance(3000);
assert.equal(f.timers.size, 0);
assert.equal(f.root.listeners.size + f.document.listeners.size, 0);
});