feat(用例管理): 用例详情新增测试计划tab页

This commit is contained in:
guoyuqi 2024-05-09 16:44:40 +08:00 committed by 刘瑞斌
parent 4f97fd5428
commit 7478a1dff0
8 changed files with 263 additions and 29 deletions

View File

@ -8,9 +8,12 @@ import lombok.Data;
public class FunctionalCaseTestPlanDTO extends TestPlanFunctionalCase { public class FunctionalCaseTestPlanDTO extends TestPlanFunctionalCase {
@Schema(description = "测试计划ID") @Schema(description = "测试计划ID")
private String testPlanNum; private Long testPlanNum;
@Schema(description = "所属项目") @Schema(description = "测试计划名称")
private String testPlanName;
@Schema(description = "所属项目名称")
private String projectName; private String projectName;
@Schema(description = "计划状态") @Schema(description = "计划状态")

View File

@ -58,7 +58,7 @@
<select id="getPlanList" parameterType="io.metersphere.functional.request.AssociatePlanPageRequest" resultType="io.metersphere.functional.dto.FunctionalCaseTestPlanDTO"> <select id="getPlanList" parameterType="io.metersphere.functional.request.AssociatePlanPageRequest" resultType="io.metersphere.functional.dto.FunctionalCaseTestPlanDTO">
SELECT SELECT
tpfc.*, tp.num as testPlanNum, tp.status as planStatus, p.name as projectName tpfc.*, tp.name as testPlanName, tp.num as testPlanNum, tp.status as planStatus, p.name as projectName
FROM FROM
test_plan_functional_case tpfc test_plan_functional_case tpfc
LEFT JOIN test_plan tp ON tpfc.test_plan_id = tp.id LEFT JOIN test_plan tp ON tpfc.test_plan_id = tp.id
@ -71,6 +71,9 @@
OR tp.num LIKE concat('%', #{request.keyword},'%') OR tp.num LIKE concat('%', #{request.keyword},'%')
) )
</if> </if>
<include refid="planFilters">
<property name="filter" value="request.filter"/>
</include>
</select> </select>
<sql id="queryWhereConditionByBaseQueryRequest"> <sql id="queryWhereConditionByBaseQueryRequest">
@ -133,5 +136,22 @@
</if> </if>
</sql> </sql>
<sql id="planFilters">
<if test="${filter} != null and ${filter}.size() > 0">
<foreach collection="${filter}.entrySet()" index="key" item="values">
<if test="values != null and values.size() > 0">
<choose>
<when test="key=='planStatus'">
AND tp.status in
<include refid="io.metersphere.system.mapper.BaseMapper.filterInWrapper"/>
</when>
<when test="key=='lastExecResult'">
AND tpfc.last_exec_result in
<include refid="io.metersphere.system.mapper.BaseMapper.filterInWrapper"/>
</when>
</choose>
</if>
</foreach>
</if>
</sql>
</mapper> </mapper>

View File

@ -36,6 +36,7 @@ import {
GetAssociatedDebuggerUrl, GetAssociatedDebuggerUrl,
GetAssociatedDrawerCaseUrl, GetAssociatedDrawerCaseUrl,
GetAssociatedFilePageUrl, GetAssociatedFilePageUrl,
GetAssociatedTestPlanUrl,
GetAssociationPublicCaseModuleCountUrl, GetAssociationPublicCaseModuleCountUrl,
GetAssociationPublicCasePageUrl, GetAssociationPublicCasePageUrl,
GetAssociationPublicModuleTreeUrl, GetAssociationPublicModuleTreeUrl,
@ -96,6 +97,7 @@ import type {
} from '@/models/caseManagement/featureCase'; } from '@/models/caseManagement/featureCase';
import type { CommonList, ModuleTreeNode, MoveModules, TableQueryParams } from '@/models/common'; import type { CommonList, ModuleTreeNode, MoveModules, TableQueryParams } from '@/models/common';
import { ProjectListItem } from '@/models/setting/project'; import { ProjectListItem } from '@/models/setting/project';
import { AssociateFunctionalCaseItem, TestPlanItem } from '@/models/testPlan/testPlan';
// 获取模块树 // 获取模块树
export function getCaseModuleTree(params: TableQueryParams) { export function getCaseModuleTree(params: TableQueryParams) {
@ -428,4 +430,9 @@ export function getAssociatedProjectOptions(orgId: string, module: string) {
return MSR.get<ProjectListItem[]>({ url: `${associatedProjectOptionsUrl}/${orgId}/${module}` }); return MSR.get<ProjectListItem[]>({ url: `${associatedProjectOptionsUrl}/${orgId}/${module}` });
} }
// 获取已关联测试计划列表
export function getLinkedCaseTestPlanList(data: TableQueryParams) {
return MSR.post<CommonList<AssociateFunctionalCaseItem>>({ url: GetAssociatedTestPlanUrl, data });
}
export default {}; export default {};

View File

@ -151,3 +151,6 @@ export const getChangeHistoryListUrl = '/functional/case/operation-history';
export const cancelDisassociate = '/functional/case/test/disassociate/case'; export const cancelDisassociate = '/functional/case/test/disassociate/case';
// 关联用例关联功能用例项目下拉 // 关联用例关联功能用例项目下拉
export const associatedProjectOptionsUrl = '/project/list/options'; export const associatedProjectOptionsUrl = '/project/list/options';
// 获取详情已关联测试计划列表
export const GetAssociatedTestPlanUrl = '/functional/case/test/has/associate/plan/page';

View File

@ -21,6 +21,14 @@ export interface TestPlanItem {
groupId: string; groupId: string;
} }
export interface AssociateFunctionalCaseItem {
testPlanId: string;
testPlanNum: number;
testPlanName: string;
projectName: string;
planStatus: string;
}
export interface ResourcesItem { export interface ResourcesItem {
id: string; id: string;
name: string; name: string;

View File

@ -141,7 +141,7 @@
<TabCaseReview :case-id="props.detailId" /> <TabCaseReview :case-id="props.detailId" />
</template> </template>
<template v-if="activeTab === 'testPlan'"> <template v-if="activeTab === 'testPlan'">
<TabTestPlan /> <TabTestPlan :case-id="props.detailId" />
</template> </template>
<template v-if="activeTab === 'comments'"> <template v-if="activeTab === 'comments'">
<TabComment ref="commentRef" :case-id="props.detailId" /> <TabComment ref="commentRef" :case-id="props.detailId" />
@ -666,6 +666,12 @@
canHide: true, canHide: true,
isShow: true, isShow: true,
}, },
{
value: 'testPlan',
label: t('caseManagement.featureCase.testPlan'),
canHide: true,
isShow: true,
},
{ {
value: 'comments', value: 'comments',
label: t('caseManagement.featureCase.comments'), label: t('caseManagement.featureCase.comments'),

View File

@ -7,14 +7,101 @@
:placeholder="t('caseManagement.featureCase.searchByNameAndId')" :placeholder="t('caseManagement.featureCase.searchByNameAndId')"
allow-clear allow-clear
class="mx-[8px] w-[240px]" class="mx-[8px] w-[240px]"
@search="searchList"
@press-enter="searchList"
@clear="searchList"
></a-input-search> ></a-input-search>
</div> </div>
<ms-base-table v-bind="propsRes" v-on="propsEvent"> <ms-base-table v-bind="propsRes" v-on="propsEvent">
<template #name="{ record }"> <template #testPlanNum="{ record }">
<a-button type="text" class="px-0">{{ record.name }}</a-button> <a-button type="text" class="px-0" @click="goToPlan(record)">{{ record.testPlanNum }}</a-button>
</template> </template>
<template #status="{ record }"> <template #planStatus="{ record }">
<statusTag :status="record.status" /> <statusTag :status="record.planStatus" />
</template>
<template #statusFilter="{ columnConfig }">
<a-trigger
v-model:popup-visible="statusFilterVisible"
trigger="click"
@popup-visible-change="handleFilterHidden"
>
<a-button
type="text"
class="arco-btn-text--secondary p-[8px_4px] text-[14px]"
@click="statusFilterVisible = true"
>
{{ t(columnConfig.title as string) }}
<icon-down :class="statusFilterVisible ? 'text-[rgb(var(--primary-5))]' : ''" />
</a-button>
<template #content>
<div class="arco-table-filters-content">
<div class="flex items-center justify-center px-[6px] py-[2px]">
<a-checkbox-group v-model:model-value="statusFilters" direction="vertical" size="small">
<a-checkbox v-for="key of Object.keys(planStatusMap)" :key="key" :value="key">
<a-tag
:color="planStatusMap[key as planStatusType].color"
:class="[planStatusMap[key as planStatusType].class, 'px-[4px]']"
size="small"
>
{{ t(planStatusMap[key as planStatusType].label) }}
</a-tag>
</a-checkbox>
</a-checkbox-group>
</div>
<div class="filter-button">
<a-button size="mini" class="mr-[8px]" @click="resetStatusFilter">
{{ t('common.reset') }}
</a-button>
<a-button type="primary" size="mini" @click="handleFilterHidden(false)">
{{ t('system.orgTemplate.confirm') }}
</a-button>
</div>
</div>
</template>
</a-trigger>
</template>
<template #lastExecResult="{ record }">
<execute-result :execute-result="record.lastExecResult" />
</template>
<template #lastExecResultFilter="{ columnConfig }">
<a-trigger
v-model:popup-visible="lastExecResultVisible"
trigger="click"
@popup-visible-change="handleFilterHidden"
>
<a-button
type="text"
class="arco-btn-text--secondary p-[8px_4px] text-[14px]"
@click="lastExecResultVisible = true"
>
{{ t(columnConfig.title as string) }}
<icon-down :class="lastExecResultVisible ? 'text-[rgb(var(--primary-5))]' : ''" />
</a-button>
<template #content>
<div class="arco-table-filters-content">
<div class="flex items-center justify-center px-[6px] py-[2px]">
<a-checkbox-group v-model:model-value="lastExecResultFilters" direction="vertical" size="small">
<a-checkbox v-for="key of Object.keys(executionResultMap)" :key="key" :value="key">
<MsIcon
:type="executionResultMap[key]?.icon || ''"
class="mr-1"
:class="[executionResultMap[key].color]"
></MsIcon>
<span>{{ executionResultMap[key]?.statusText || '' }} </span>
</a-checkbox>
</a-checkbox-group>
</div>
<div class="filter-button">
<a-button size="mini" class="mr-[8px]" @click="resetLastExecuteResultFilter">
{{ t('common.reset') }}
</a-button>
<a-button type="primary" size="mini" @click="handleFilterHidden(false)">
{{ t('system.orgTemplate.confirm') }}
</a-button>
</div>
</div>
</template>
</a-trigger>
</template> </template>
</ms-base-table> </ms-base-table>
</div> </div>
@ -22,34 +109,48 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { debounce } from 'lodash-es';
import dayjs from 'dayjs';
import MsIcon from '@/components/pure/ms-icon-font/index.vue';
import MsBaseTable from '@/components/pure/ms-table/base-table.vue'; import MsBaseTable from '@/components/pure/ms-table/base-table.vue';
import type { MsTableColumn } from '@/components/pure/ms-table/type'; import type { MsTableColumn } from '@/components/pure/ms-table/type';
import useTable from '@/components/pure/ms-table/useTable'; import useTable from '@/components/pure/ms-table/useTable';
import ExecuteResult from '@/components/business/ms-case-associate/executeResult.vue';
import statusTag from '@/views/case-management/caseReview/components/statusTag.vue'; import statusTag from '@/views/case-management/caseReview/components/statusTag.vue';
import { getRecycleListRequest } from '@/api/modules/case-management/featureCase'; import { getLinkedCaseTestPlanList } from '@/api/modules/case-management/featureCase';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';
import useFeatureCaseStore from '@/store/modules/case/featureCase';
import { AssociateFunctionalCaseItem, planStatusType } from '@/models/testPlan/testPlan';
import { TestPlanRouteEnum } from '@/enums/routeEnum';
import { TableKeyEnum } from '@/enums/tableEnum'; import { TableKeyEnum } from '@/enums/tableEnum';
const featureCaseStore = useFeatureCaseStore(); import { executionResultMap } from '@/views/case-management/caseManagementFeature/components/utils';
const activeTab = computed(() => featureCaseStore.activeTab); import { planStatusMap } from '@/views/test-plan/testPlan/config';
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const keyword = ref<string>(''); const keyword = ref<string>('');
const props = defineProps<{
caseId: string; // id
}>();
const columns: MsTableColumn = [ const columns: MsTableColumn = [
{ {
title: 'caseManagement.featureCase.defectID', title: 'ID',
dataIndex: 'id', dataIndex: 'testPlanNum',
slotName: 'testPlanNum',
showTooltip: true, showTooltip: true,
width: 90, width: 90,
}, },
{ {
title: 'caseManagement.featureCase.testPlanName', title: 'caseManagement.featureCase.testPlanName',
slotName: 'name', slotName: 'testPlanName',
dataIndex: 'name', dataIndex: 'testPlanName',
width: 200, width: 200,
}, },
{ {
@ -62,28 +163,114 @@
title: 'caseManagement.featureCase.planStatus', title: 'caseManagement.featureCase.planStatus',
slotName: 'planStatus', slotName: 'planStatus',
dataIndex: 'planStatus', dataIndex: 'planStatus',
titleSlotName: 'statusFilter',
width: 200, width: 200,
}, },
{ {
title: 'caseManagement.featureCase.tableColumnExecutionResult', title: 'caseManagement.featureCase.tableColumnExecutionResult',
slotName: 'executionResult', slotName: 'lastExecResult',
dataIndex: 'executionResult', dataIndex: 'lastExecResult',
titleSlotName: 'lastExecResultFilter',
width: 200, width: 200,
}, },
{ {
title: 'caseManagement.featureCase.executionTime', title: 'caseManagement.featureCase.executionTime',
slotName: 'executionTime', slotName: 'lastExecTime',
dataIndex: 'executionTime', dataIndex: 'lastExecTime',
width: 200, width: 200,
showInTable: false,
sortable: {
sortDirections: ['ascend', 'descend'],
sorter: true,
},
showDrag: true,
}, },
]; ];
const { propsRes, propsEvent, loadList, setLoadListParams } = useTable(getRecycleListRequest, { const statusFilterVisible = ref(false);
columns, const statusFilters = ref<string[]>([]);
tableKey: TableKeyEnum.CASE_MANAGEMENT_TAB_TEST_PLAN,
scroll: { x: '100%' }, const lastExecResultVisible = ref(false);
heightUsed: 340, const lastExecResultFilters = ref<string[]>([]);
enableDrag: true,
const { propsRes, propsEvent, loadList, setLoadListParams } = useTable(
getLinkedCaseTestPlanList,
{
columns,
tableKey: TableKeyEnum.CASE_MANAGEMENT_TAB_TEST_PLAN,
scroll: { x: '100%' },
heightUsed: 340,
enableDrag: false,
},
(item) => {
return {
...item,
lastExecTime: item.lastExecTime ? `${dayjs(item.lastExecTime).format('YYYY-MM-DD HH:mm:ss')}` : '',
};
}
);
async function initData() {
setLoadListParams({
keyword: keyword.value,
caseId: props.caseId,
filter: {
planStatus: statusFilters.value,
lastExecResult: lastExecResultFilters.value,
},
});
await loadList();
}
const searchList = debounce(() => {
initData();
}, 100);
function handleFilterHidden(val: boolean) {
if (!val) {
statusFilterVisible.value = false;
lastExecResultVisible.value = false;
searchList();
}
}
function resetStatusFilter() {
statusFilterVisible.value = false;
statusFilters.value = [];
searchList();
}
function resetLastExecuteResultFilter() {
lastExecResultVisible.value = false;
lastExecResultFilters.value = [];
searchList();
}
//
function goToPlan(record: AssociateFunctionalCaseItem) {
router.push({
name: TestPlanRouteEnum.TEST_PLAN_INDEX,
query: {
...route.query,
id: record.testPlanId,
},
state: {
params: JSON.stringify(setLoadListParams()),
},
});
}
watch(
() => props.caseId,
(val) => {
if (val) {
initData();
}
}
);
onMounted(() => {
initData();
}); });
</script> </script>

View File

@ -65,12 +65,12 @@ export const executionResultMap: Record<string, any> = {
statusText: t('caseManagement.featureCase.passed'), statusText: t('caseManagement.featureCase.passed'),
color: '', color: '',
}, },
SKIPPED: { /* SKIPPED: {
key: 'SKIPPED', key: 'SKIPPED',
icon: StatusType.SKIPPED, icon: StatusType.SKIPPED,
statusText: t('caseManagement.featureCase.skip'), statusText: t('caseManagement.featureCase.skip'),
color: 'text-[rgb(var(--link-6))]', color: 'text-[rgb(var(--link-6))]',
}, }, */
BLOCKED: { BLOCKED: {
key: 'BLOCKED', key: 'BLOCKED',
icon: StatusType.BLOCKED, icon: StatusType.BLOCKED,