mirror of
https://github.com/kuaifan/dootask.git
synced 2026-09-12 15:09:51 +00:00
feat(project): 支持表格分组配置与悬停筛选菜单
新增到期、排期、逾期与未排期分组,按项目缓存显示选择并至少保留一项。 统一分组复选下拉菜单,接入 Cascader 桌面悬停展开,增加勾选后的滚动定位校正。 升级 view-design-hi 至 4.7.0-81,同步多语言、知识库和回归测试。
This commit is contained in:
parent
a3bfc74159
commit
ffbb45708d
@ -2741,3 +2741,9 @@ WebDAV 路径冲突
|
||||
确定要删除该优先级吗?
|
||||
登录页 Logo
|
||||
用于 Web 端和客户端登录页,未设置时使用默认 Logo。
|
||||
本周到期
|
||||
下周到期
|
||||
本周排期
|
||||
下周排期
|
||||
已逾期
|
||||
未排期
|
||||
|
||||
@ -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é.",
|
||||
"id": "Digunakan pada halaman login Web dan klien. Logo default digunakan jika tidak dikonfigurasi.",
|
||||
"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": "Не запланированы"
|
||||
}
|
||||
]
|
||||
|
||||
@ -67,7 +67,7 @@
|
||||
"stylus-loader": "^7.1.0",
|
||||
"tinymce": "^5.10.3",
|
||||
"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-plugin-file-copy": "^1.0.0",
|
||||
"vite-plugin-require": "^1.1.10",
|
||||
|
||||
2
public/language/web/de.js
vendored
2
public/language/web/de.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/en.js
vendored
2
public/language/web/en.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/fr.js
vendored
2
public/language/web/fr.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/id.js
vendored
2
public/language/web/id.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/ja.js
vendored
2
public/language/web/ja.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/key.js
vendored
2
public/language/web/key.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/ko.js
vendored
2
public/language/web/ko.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/ru.js
vendored
2
public/language/web/ru.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/zh-CHT.js
vendored
2
public/language/web/zh-CHT.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/zh.js
vendored
2
public/language/web/zh.js
vendored
File diff suppressed because one or more lines are too long
@ -20,14 +20,15 @@ negative:
|
||||
- 一次只能选一个筛选条件(不支持「负责人 A + 标签 B」组合)
|
||||
- 移动端的筛选 Cascader 与桌面端共用,但下拉空间有限
|
||||
- 没有「保存常用筛选」功能
|
||||
last_verified: v1.7.90
|
||||
last_verified: v1.9.18
|
||||
---
|
||||
|
||||
# 视图筛选条件
|
||||
|
||||
## 入口
|
||||
- 桌面端 Web:项目详情页顶部,视图切换条左侧的 Cascader(默认显示「全部 (n)」)
|
||||
- 移动端:项目详情页顶部同位置
|
||||
- 桌面非触屏端:鼠标悬停展开,移入下拉菜单保持展开,移出入口与菜单后延迟关闭
|
||||
- 触屏设备和 App:项目详情页顶部同位置,点击展开
|
||||
|
||||
## 可用筛选维度
|
||||
- **全部**:不筛选
|
||||
|
||||
@ -21,14 +21,14 @@ negative:
|
||||
- 一个项目可以有多条工作流,但筛选 Cascader 一次只能选一个工作流的某个状态
|
||||
- 项目未配置工作流时筛选下拉只显示「全部」与「未计划」
|
||||
- 工作流状态(flow_item)不等于看板列(column),两者独立
|
||||
last_verified: v1.7.90
|
||||
last_verified: v1.9.18
|
||||
---
|
||||
|
||||
# 按工作流状态分栏查看任务
|
||||
|
||||
## 入口
|
||||
- 桌面端 Web:项目详情页顶部「工作流」Cascader(默认显示「全部 (n)」)
|
||||
- 点开下拉 → 选某个工作流的某个状态(如「评审 → 评审中」)
|
||||
- 桌面非触屏端悬停展开下拉;触屏设备和 App 点击展开,再选择某个工作流的某个状态(如「评审 → 评审中」)
|
||||
|
||||
## 操作步骤
|
||||
1. 确保项目已配置工作流(项目设置 → 工作流设置;详见 [[project.flow.howto.create]])
|
||||
|
||||
@ -12,6 +12,9 @@ aliases:
|
||||
- 表格视图
|
||||
- 按行看任务
|
||||
- table 视图
|
||||
- 怎么选择本周任务和下周到期
|
||||
- 怎么看本周排期和逾期任务
|
||||
- 隐藏协助任务分组
|
||||
related_tools: [list_tasks]
|
||||
related_pages: [project_detail]
|
||||
prerequisites: []
|
||||
@ -20,7 +23,8 @@ negative:
|
||||
- 不支持双击编辑(点击任务行打开任务详情)
|
||||
- 没有 Excel 式选区批量操作
|
||||
- 行内排序仅 优先级 / 到期时间 两列可点
|
||||
last_verified: v1.7.90
|
||||
- 至少保留一个分组,不能全部取消
|
||||
last_verified: v1.9.18
|
||||
---
|
||||
|
||||
# 使用列表视图查看任务
|
||||
@ -30,14 +34,28 @@ last_verified: v1.7.90
|
||||
- 视图切换记忆在 `cacheParameter.menuType='table'`,下次进入同一项目仍是列表视图
|
||||
|
||||
## 列表分段
|
||||
切到列表视图后,任务自动按归属分成 4 段:
|
||||
先切换到表格模式,鼠标悬停视图切换组前的设置齿轮图标,下拉菜单中可复选分组。菜单与项目筛选使用一致的弹层外观,无箭头和标题栏。触屏先切换表格,再点击齿轮打开选择。表格图标仅切换视图,悬停或点击都不会打开分组菜单。看板和甘特图模式不显示设置齿轮;切走表格时菜单关闭。
|
||||
|
||||
1. **我的任务**:当前用户是负责人的任务
|
||||
2. **协助的任务**:当前用户是协助者的任务(仅当存在时显示)
|
||||
3. **未完成任务**:项目内其他未完成任务
|
||||
4. **已完成任务**:项目内已完成任务(仅当 task_num > 0 时显示)
|
||||
| 分组 | 默认显示 | 范围 |
|
||||
|---|---|---|
|
||||
| 我的任务 | 是 | 当前用户负责的任务 |
|
||||
| 协助的任务 | 是 | 当前用户协助的主任务 |
|
||||
| 未完成任务 | 是 | 项目内未完成主任务,包含本人任务 |
|
||||
| 已完成任务 | 否 | 项目内已完成主任务 |
|
||||
| 本周到期 / 下周到期 | 否 | 到期时间落在对应周的主任务 |
|
||||
| 本周排期 / 下周排期 | 否 | 计划开始、结束时间完整且与对应周相交的主任务 |
|
||||
| 已逾期 | 否 | 未完成,且到期时间早于当前时间的主任务 |
|
||||
| 未排期 | 否 | 未完成,且缺少计划开始时间或结束时间的主任务 |
|
||||
|
||||
每段标题旁有箭头可折叠 / 展开,状态记忆在 `cacheParameter.showMy / showHelp / showUndone / showCompleted`。
|
||||
每周从周一零点到下周一零点(不含),使用系统现有时区口径。跨周计划可同时出现在两周分组,同一任务也可出现在多个分组;子任务沿用行内展开。
|
||||
|
||||
所有分组均可取消,但至少保留一项。勾选后即使数量为 0 也显示标题和空状态,不再按是否有任务隐藏。标题箭头独立控制折叠;新勾选自动展开,并在表格内滚动到该分组标题(靠近底部时以最大可滚动位置为限)。取消勾选不触发定位;横向滚动位置和顶部设置入口保持不变,菜单保持打开,可继续复选。
|
||||
|
||||
定位会等待进入视口的任务渲染,再重新测量并校正高度变化造成的偏移。初次滚动平滑,后续校正不重复启动动画;位置稳定后停止,最长持续 2.5 秒。手动滚轮、触屏、拖动滚动条、滚动按键,以及更改分组选择、切换项目或视图会中断旧定位。超时后不再持续追踪后续高度变化。
|
||||
|
||||
显示选择存于项目本地缓存 `cacheParameter.tableGroups`,与视图选择同一缓存机制,不跨设备同步;不覆盖原有折叠状态。搜索和顶部筛选仍生效。“显示已完成”影响我的、协助及四个周分组,不影响独立的已完成分组。
|
||||
|
||||
“本周排期 / 下周排期”原名为“本周任务 / 下周任务”,仅修改名称,原有选择和折叠缓存保留。已逾期与未排期始终只含未完成任务,不受“显示已完成”开启影响;已逾期会随当前时间自动更新。
|
||||
|
||||
## 列结构
|
||||
列表视图列固定为:
|
||||
|
||||
@ -19,7 +19,7 @@ negative:
|
||||
- 没有快捷键切换视图
|
||||
- 移动端视图切换按钮位置与桌面一致,但小屏更靠右
|
||||
- 工作流视图不是这里切换,是通过工作流筛选 Cascader 叠加
|
||||
last_verified: v1.7.90
|
||||
last_verified: v1.9.18
|
||||
---
|
||||
|
||||
# 切换看板 / 列表 / 甘特图视图
|
||||
@ -37,6 +37,8 @@ last_verified: v1.7.90
|
||||
|
||||
切换器底部有一条滑块(slider)会跟随高亮,所选视图按钮变色。
|
||||
|
||||
仅在表格模式下,悬停切换组前的设置齿轮图标会弹出分组复选下拉菜单;触屏切换表格后点击齿轮打开。菜单与项目筛选弹层风格一致,无箭头和标题栏,勾选后保持打开。列表图标只切换视图,不触发菜单。切走表格后齿轮隐藏、菜单关闭。可选择我的、协助、未完成、已完成、本周到期、下周到期、本周排期、下周排期、已逾期、未排期,至少保留一项。默认只选择我的、协助、未完成,其余默认不选。选择按项目本地缓存,与折叠状态独立,详见 [[view.list.howto]]。
|
||||
|
||||
## 操作步骤
|
||||
1. 进入项目详情页
|
||||
2. 点切换条上对应图标
|
||||
|
||||
@ -101,10 +101,27 @@
|
||||
<Checkbox :value="projectData.cacheParameter.completedTask" @on-change="toggleParameter('completedTask')">{{$L('显示已完成')}}</Checkbox>
|
||||
</div>
|
||||
<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>
|
||||
</Cascader>
|
||||
</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="slider" :style="tabTypeStyle"></div>
|
||||
<div @click="tabTypeChange('column')" :class="{ 'active': tabTypeActive === 'column'}"><i class="taskfont"></i></div>
|
||||
@ -314,7 +331,7 @@
|
||||
</Row>
|
||||
</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">
|
||||
<Col span="12" class="row-title">
|
||||
<i class="taskfont" @click="toggleParameter('showMy')"></i>
|
||||
@ -334,9 +351,10 @@
|
||||
open-key="my"
|
||||
@on-priority="addTaskOpen"
|
||||
fast-add-task/>
|
||||
<div v-if="projectData.cacheParameter.showMy && !myList.length" class="project-table-empty">{{$L('暂无任务')}}</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">
|
||||
<Col span="12" class="row-title">
|
||||
<i class="taskfont" @click="toggleParameter('showHelp')"></i>
|
||||
@ -355,9 +373,10 @@
|
||||
:readonly="isDepartmentReadonly"
|
||||
open-key="help"
|
||||
@on-priority="addTaskOpen"/>
|
||||
<div v-if="projectData.cacheParameter.showHelp && !helpList.length" class="project-table-empty">{{$L('暂无任务')}}</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">
|
||||
<Col span="12" class="row-title">
|
||||
<i class="taskfont" @click="toggleParameter('showUndone')"></i>
|
||||
@ -376,9 +395,10 @@
|
||||
:readonly="isDepartmentReadonly"
|
||||
open-key="undone"
|
||||
@on-priority="addTaskOpen"/>
|
||||
<div v-if="projectData.cacheParameter.showUndone && !unList.length" class="project-table-empty">{{$L('暂无任务')}}</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">
|
||||
<Col span="12" class="row-title">
|
||||
<i class="taskfont" @click="toggleParameter('showCompleted')"></i>
|
||||
@ -389,7 +409,7 @@
|
||||
<Col span="3"></Col>
|
||||
<Col span="3"></Col>
|
||||
<Col span="3">
|
||||
<div class="ellipsis">{{projectData.task_num > 0 && projectData.cacheParameter.showCompleted ? $L('完成时间') : ''}}</div>
|
||||
<div class="ellipsis">{{projectData.cacheParameter.showCompleted ? $L('完成时间') : ''}}</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<TaskRow
|
||||
@ -400,6 +420,32 @@
|
||||
open-key="completed"
|
||||
@on-priority="addTaskOpen"
|
||||
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)"></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>
|
||||
</Scrollbar>
|
||||
<div v-else-if="tabTypeActive === 'gantt'" class="project-gantt">
|
||||
@ -650,11 +696,14 @@ import TaskMenu from "./TaskMenu";
|
||||
import TaskDeleted from "./TaskDeleted";
|
||||
import TaskTag from "./ProjectTaskTag/tags.vue";
|
||||
import ProjectGantt from "./ProjectGantt";
|
||||
import ProjectTableGroupDropdown from "./ProjectTableGroupDropdown.vue";
|
||||
import UserSelect from "../../../components/UserSelect.vue";
|
||||
import UserAvatarTip from "../../../components/UserAvatar/tip.vue";
|
||||
import VMPreviewNostyle from "../../../components/VMEditor/nostyle.vue";
|
||||
import emitter from "../../../store/events";
|
||||
import transformEmojiToHtml from "../../../utils/emoji";
|
||||
import {projectTableGroups, normalizeProjectTableGroups, projectWeekRanges, taskMatchesProjectGroup} from "../../../utils/projectTableGroups";
|
||||
import {scrollToStableTarget} from "../../../utils/scrollToStableTarget";
|
||||
|
||||
export default {
|
||||
name: "ProjectPanel",
|
||||
@ -677,6 +726,7 @@ export default {
|
||||
TaskDeleted,
|
||||
TaskTag,
|
||||
ProjectGantt,
|
||||
ProjectTableGroupDropdown,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@ -684,6 +734,8 @@ export default {
|
||||
|
||||
nowTime: $A.dayjs().unix(),
|
||||
nowInterval: null,
|
||||
tableToday: $A.daytz().format('YYYY-MM-DD'),
|
||||
tableGroupsOpen: false,
|
||||
|
||||
columnLoad: {},
|
||||
columnTopShow: {},
|
||||
@ -740,10 +792,12 @@ export default {
|
||||
mounted() {
|
||||
this.nowInterval = setInterval(() => {
|
||||
this.nowTime = $A.dayjs().unix();
|
||||
this.tableToday = $A.daytz().format('YYYY-MM-DD');
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this.cancelTableGroupScroll();
|
||||
clearInterval(this.nowInterval);
|
||||
},
|
||||
|
||||
@ -771,6 +825,35 @@ export default {
|
||||
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() {
|
||||
const style = {}
|
||||
switch (this.tabTypeActive) {
|
||||
@ -1182,8 +1265,23 @@ export default {
|
||||
},
|
||||
|
||||
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: {
|
||||
handler(id) {
|
||||
this.cancelTableGroupScroll();
|
||||
this.tableGroupsOpen = false;
|
||||
if (id > 0) {
|
||||
this.getFlowData();
|
||||
this.handleColumnDebounce();
|
||||
@ -1949,6 +2047,47 @@ export default {
|
||||
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) {
|
||||
switch (type) {
|
||||
case "column":
|
||||
|
||||
@ -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>
|
||||
48
resources/assets/js/utils/projectTableGroups.js
vendored
Normal file
48
resources/assets/js/utils/projectTableGroups.js
vendored
Normal 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);
|
||||
}
|
||||
60
resources/assets/js/utils/scrollToStableTarget.js
vendored
Normal file
60
resources/assets/js/utils/scrollToStableTarget.js
vendored
Normal 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;
|
||||
}
|
||||
@ -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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -653,6 +675,13 @@
|
||||
border: 1px solid #F4F4F5;
|
||||
border-bottom: 0;
|
||||
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 {
|
||||
.task-rows {
|
||||
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 {
|
||||
max-height: calc(var(--window-height) - var(--status-bar-height) - var(--navigation-bar-height) - 120px);
|
||||
overflow-y: auto;
|
||||
|
||||
302
tests/project-table-groups.browser.cjs
Normal file
302
tests/project-table-groups.browser.cjs
Normal 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;});
|
||||
350
tests/project-table-groups.test.mjs
Normal file
350
tests/project-table-groups.test.mjs
Normal 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);
|
||||
});
|
||||
110
tests/scroll-to-stable-target.test.mjs
Normal file
110
tests/scroll-to-stable-target.test.mjs
Normal 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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user