fix(version): 限制任务需求候选为版本已关联需求
This commit is contained in:
@@ -28,7 +28,7 @@ import { calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task';
|
|||||||
import { hasPermission } from '@/lib/permissions';
|
import { hasPermission } from '@/lib/permissions';
|
||||||
import { calcActualElapsedHours, formatActualDuration } from '@/lib/work-hours';
|
import { calcActualElapsedHours, formatActualDuration } from '@/lib/work-hours';
|
||||||
import { formatDateTime, formatLocalDate } from '@/lib/format';
|
import { formatDateTime, formatLocalDate } from '@/lib/format';
|
||||||
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
|
import { getVersionLinkedRequirementCandidates } from '@/lib/requirement-selector';
|
||||||
import { getRequirementVersionSelectionPatch, getRequirementVersionUnlinkPatch } from '@/lib/requirement-version-link';
|
import { getRequirementVersionSelectionPatch, getRequirementVersionUnlinkPatch } from '@/lib/requirement-version-link';
|
||||||
import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetrics, calcVersionOverviewEffortTotals } from '@/lib/version-overview';
|
import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetrics, calcVersionOverviewEffortTotals } from '@/lib/version-overview';
|
||||||
import { addVersionMembers, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members';
|
import { addVersionMembers, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members';
|
||||||
@@ -1022,7 +1022,7 @@ export default function VersionDetailPage() {
|
|||||||
) : (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') ? (
|
) : (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') ? (
|
||||||
(() => {
|
(() => {
|
||||||
const pt = activeTab as 'research' | 'product' | 'ui';
|
const pt = activeTab as 'research' | 'product' | 'ui';
|
||||||
const projectAdoptedReqs = getProjectAdoptedRequirementCandidates(displayRequirements, version.projectId);
|
const versionLinkedReqs = getVersionLinkedRequirementCandidates(displayRequirements, displayRequirementIds);
|
||||||
return (
|
return (
|
||||||
<PlanTab
|
<PlanTab
|
||||||
plans={displayPlans}
|
plans={displayPlans}
|
||||||
@@ -1032,7 +1032,7 @@ export default function VersionDetailPage() {
|
|||||||
currentUserName={user?.name ?? ''}
|
currentUserName={user?.name ?? ''}
|
||||||
planType={pt}
|
planType={pt}
|
||||||
versionMembers={version.members ?? []}
|
versionMembers={version.members ?? []}
|
||||||
linkedRequirements={projectAdoptedReqs}
|
linkedRequirements={versionLinkedReqs}
|
||||||
allRequirements={displayRequirements}
|
allRequirements={displayRequirements}
|
||||||
readOnly={versionReadonly || !planWriteReady}
|
readOnly={versionReadonly || !planWriteReady}
|
||||||
onCreate={(data) => {
|
onCreate={(data) => {
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ import { useMemberStore } from '@/stores/useMemberStore';
|
|||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import { FilterSelect } from '@/components/FilterSelect';
|
import { FilterSelect } from '@/components/FilterSelect';
|
||||||
import { WorkDateTimePicker } from '@/components/WorkDateTimePicker';
|
import { WorkDateTimePicker } from '@/components/WorkDateTimePicker';
|
||||||
|
import { getVersionLinkedRequirementCandidates } from '@/lib/requirement-selector';
|
||||||
import { calcWorkHours, formatWorkHours, isoToLocal, localToISO } from '@/lib/work-hours';
|
import { calcWorkHours, formatWorkHours, isoToLocal, localToISO } from '@/lib/work-hours';
|
||||||
import type { Priority } from '@/lib/derive';
|
import type { Priority } from '@/lib/derive';
|
||||||
|
import type { Requirement } from '@/lib/requirement';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
versionId: string;
|
versionId: string;
|
||||||
requirementIds: string[];
|
requirementIds: string[];
|
||||||
|
versionRequirements?: Requirement[];
|
||||||
versionDeadline?: string;
|
versionDeadline?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreated?: () => void;
|
onCreated?: () => void;
|
||||||
@@ -32,16 +35,17 @@ function defaultExpectedEnd(): string {
|
|||||||
return isoToLocal(d.toISOString());
|
return isoToLocal(d.toISOString());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline, onClose, onCreated }: Props) {
|
export function DevTaskCreateModal({ versionId, requirementIds, versionRequirements, versionDeadline, onClose, onCreated }: Props) {
|
||||||
const { createTask, tasks } = useDevTaskStore();
|
const { createTask, tasks } = useDevTaskStore();
|
||||||
const { categories } = useTaskCategoryStore();
|
const { categories } = useTaskCategoryStore();
|
||||||
const { requirements } = useRequirementStore();
|
const { requirements } = useRequirementStore();
|
||||||
const { members } = useMemberStore();
|
const { members } = useMemberStore();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
|
const requirementSource = versionRequirements ?? requirements;
|
||||||
const versionReqs = useMemo(
|
const versionReqs = useMemo(
|
||||||
() => requirements.filter((r) => requirementIds.includes(r.id)),
|
() => getVersionLinkedRequirementCandidates(requirementSource, requirementIds),
|
||||||
[requirements, requirementIds],
|
[requirementSource, requirementIds],
|
||||||
);
|
);
|
||||||
const versionTasks = useMemo(
|
const versionTasks = useMemo(
|
||||||
() => tasks.filter((t) => t.versionId === versionId || requirementIds.includes(t.requirementId)),
|
() => tasks.filter((t) => t.versionId === versionId || requirementIds.includes(t.requirementId)),
|
||||||
@@ -65,6 +69,17 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline,
|
|||||||
if (categories[0] && !categoryId) setCategoryId(categories[0].id);
|
if (categories[0] && !categoryId) setCategoryId(categories[0].id);
|
||||||
}, [categories, categoryId]);
|
}, [categories, categoryId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (versionReqs.length === 0) {
|
||||||
|
if (requirementId) setRequirementId('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (versionReqs.some((r) => r.id === requirementId)) return;
|
||||||
|
const nextRequirement = versionReqs[0];
|
||||||
|
setRequirementId(nextRequirement.id);
|
||||||
|
if (!priorityManuallySet) setPriority(nextRequirement.priority);
|
||||||
|
}, [versionReqs, requirementId, priorityManuallySet]);
|
||||||
|
|
||||||
const expectedStartISO = localToISO(expectedStartLocal);
|
const expectedStartISO = localToISO(expectedStartLocal);
|
||||||
const expectedEndISO = localToISO(expectedEndLocal);
|
const expectedEndISO = localToISO(expectedEndLocal);
|
||||||
|
|
||||||
|
|||||||
@@ -254,7 +254,15 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnl
|
|||||||
|
|
||||||
{total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />}
|
{total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />}
|
||||||
|
|
||||||
{showCreate && !readOnly && <DevTaskCreateModal versionId={versionId} requirementIds={requirementIds} versionDeadline={versionDeadline} onClose={() => setShowCreate(false)} />}
|
{showCreate && !readOnly && (
|
||||||
|
<DevTaskCreateModal
|
||||||
|
versionId={versionId}
|
||||||
|
requirementIds={requirementIds}
|
||||||
|
versionRequirements={versionRequirements}
|
||||||
|
versionDeadline={versionDeadline}
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{selectedTaskId && <DevTaskDetailDrawer taskId={selectedTaskId} allTaskIds={allTaskIds} readOnly={readOnly} onClose={() => setSelectedTaskId(null)} />}
|
{selectedTaskId && <DevTaskDetailDrawer taskId={selectedTaskId} allTaskIds={allTaskIds} readOnly={readOnly} onClose={() => setSelectedTaskId(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,13 +10,16 @@ import { useAuthStore } from '@/stores/useAuthStore';
|
|||||||
import { FilterSelect } from '@/components/FilterSelect';
|
import { FilterSelect } from '@/components/FilterSelect';
|
||||||
import { WorkDateTimePicker } from '@/components/WorkDateTimePicker';
|
import { WorkDateTimePicker } from '@/components/WorkDateTimePicker';
|
||||||
import type { Priority } from '@/lib/derive';
|
import type { Priority } from '@/lib/derive';
|
||||||
|
import type { Requirement } from '@/lib/requirement';
|
||||||
import { getCategoriesByGroup, getDefaultCategoryByGroup } from '@/lib/task-category';
|
import { getCategoriesByGroup, getDefaultCategoryByGroup } from '@/lib/task-category';
|
||||||
|
import { getVersionLinkedRequirementCandidates } from '@/lib/requirement-selector';
|
||||||
import { clampTestCaseEstimateHours, getDefaultTestCaseEstimateHours } from '@/lib/ai-estimation-policy';
|
import { clampTestCaseEstimateHours, getDefaultTestCaseEstimateHours } from '@/lib/ai-estimation-policy';
|
||||||
import { isoToLocal, localToISO } from '@/lib/work-hours';
|
import { isoToLocal, localToISO } from '@/lib/work-hours';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
versionId: string;
|
versionId: string;
|
||||||
requirementIds: string[];
|
requirementIds: string[];
|
||||||
|
versionRequirements?: Requirement[];
|
||||||
roundNo?: number;
|
roundNo?: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
@@ -33,16 +36,17 @@ function defaultPlannedEndLocal(): string {
|
|||||||
return isoToLocal(d.toISOString());
|
return isoToLocal(d.toISOString());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClose }: Props) {
|
export function TestCaseCreateModal({ versionId, requirementIds, versionRequirements, roundNo, onClose }: Props) {
|
||||||
const { createTestCase } = useTestCaseStore();
|
const { createTestCase } = useTestCaseStore();
|
||||||
const { requirements } = useRequirementStore();
|
const { requirements } = useRequirementStore();
|
||||||
const { members } = useMemberStore();
|
const { members } = useMemberStore();
|
||||||
const { categories } = useTaskCategoryStore();
|
const { categories } = useTaskCategoryStore();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
|
const requirementSource = versionRequirements ?? requirements;
|
||||||
const versionReqs = useMemo(
|
const versionReqs = useMemo(
|
||||||
() => requirements.filter((r) => requirementIds.includes(r.id)),
|
() => getVersionLinkedRequirementCandidates(requirementSource, requirementIds),
|
||||||
[requirements, requirementIds],
|
[requirementSource, requirementIds],
|
||||||
);
|
);
|
||||||
const testCategories = useMemo(() => getCategoriesByGroup(categories, 'testing'), [categories]);
|
const testCategories = useMemo(() => getCategoriesByGroup(categories, 'testing'), [categories]);
|
||||||
|
|
||||||
@@ -69,6 +73,12 @@ export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClos
|
|||||||
setEstimateHours(getDefaultTestCaseEstimateHours(next.code));
|
setEstimateHours(getDefaultTestCaseEstimateHours(next.code));
|
||||||
}, [categoryId, testCategories]);
|
}, [categoryId, testCategories]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!requirementId) return;
|
||||||
|
if (versionReqs.some((r) => r.id === requirementId)) return;
|
||||||
|
setRequirementId('');
|
||||||
|
}, [versionReqs, requirementId]);
|
||||||
|
|
||||||
const handleCategoryChange = (nextCategoryId: string) => {
|
const handleCategoryChange = (nextCategoryId: string) => {
|
||||||
setCategoryId(nextCategoryId);
|
setCategoryId(nextCategoryId);
|
||||||
const category = categories.find((c) => c.id === nextCategoryId) ?? testCategories.find((c) => c.id === nextCategoryId);
|
const category = categories.find((c) => c.id === nextCategoryId) ?? testCategories.find((c) => c.id === nextCategoryId);
|
||||||
|
|||||||
@@ -332,7 +332,15 @@ export function TestCaseTab({
|
|||||||
|
|
||||||
{total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />}
|
{total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />}
|
||||||
|
|
||||||
{showCreate && !readOnly && <TestCaseCreateModal versionId={versionId} requirementIds={requirementIds} roundNo={activeRound} onClose={() => setShowCreate(false)} />}
|
{showCreate && !readOnly && (
|
||||||
|
<TestCaseCreateModal
|
||||||
|
versionId={versionId}
|
||||||
|
requirementIds={requirementIds}
|
||||||
|
versionRequirements={versionRequirements}
|
||||||
|
roundNo={activeRound}
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{selectedCaseId && <TestCaseDetailDrawer testCaseId={selectedCaseId} readOnly={readOnly} onClose={() => setSelectedCaseId(null)} onCreateBug={(id) => { if (!readOnly) setBugForCaseId(id); }} />}
|
{selectedCaseId && <TestCaseDetailDrawer testCaseId={selectedCaseId} readOnly={readOnly} onClose={() => setSelectedCaseId(null)} onCreateBug={(id) => { if (!readOnly) setBugForCaseId(id); }} />}
|
||||||
{bugForCaseId && !readOnly && <BugCreateModal testCaseId={bugForCaseId} onClose={() => setBugForCaseId(null)} />}
|
{bugForCaseId && !readOnly && <BugCreateModal testCaseId={bugForCaseId} onClose={() => setBugForCaseId(null)} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
import { getProjectAdoptedRequirementCandidates, mergeSelectedRequirementOptions } from './requirement-selector';
|
import {
|
||||||
|
getProjectAdoptedRequirementCandidates,
|
||||||
|
getVersionLinkedRequirementCandidates,
|
||||||
|
mergeSelectedRequirementOptions,
|
||||||
|
} from './requirement-selector';
|
||||||
import type { Requirement } from './requirement';
|
import type { Requirement } from './requirement';
|
||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
@@ -38,6 +42,17 @@ test('returns only adopted requirements from current project', () => {
|
|||||||
assert.deepEqual(result.map((r: Requirement) => r.id), ['r1']);
|
assert.deepEqual(result.map((r: Requirement) => r.id), ['r1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('returns only requirements already linked to current version', () => {
|
||||||
|
const result = getVersionLinkedRequirementCandidates([
|
||||||
|
req('r1', 'project-1', 'adopted'),
|
||||||
|
req('r2', 'project-1', 'developing'),
|
||||||
|
req('r3', 'project-1', 'adopted'),
|
||||||
|
req('r4', 'project-2', 'adopted'),
|
||||||
|
], ['r2', 'r1']);
|
||||||
|
|
||||||
|
assert.deepEqual(result.map((r: Requirement) => r.id), ['r2', 'r1']);
|
||||||
|
});
|
||||||
|
|
||||||
test('keeps historical selected requirements as non-candidate options', () => {
|
test('keeps historical selected requirements as non-candidate options', () => {
|
||||||
const options = mergeSelectedRequirementOptions(
|
const options = mergeSelectedRequirementOptions(
|
||||||
[req('r1', 'project-1', 'adopted')],
|
[req('r1', 'project-1', 'adopted')],
|
||||||
|
|||||||
@@ -26,6 +26,22 @@ export function getProjectAdoptedRequirementCandidates(requirements: Requirement
|
|||||||
.sort((a, b) => a.code.localeCompare(b.code, 'zh-CN'));
|
.sort((a, b) => a.code.localeCompare(b.code, 'zh-CN'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getVersionLinkedRequirementCandidates(requirements: Requirement[], requirementIds: string[]): Requirement[] {
|
||||||
|
const linkedOrder = new Map<string, number>();
|
||||||
|
requirementIds.forEach((id, index) => {
|
||||||
|
if (!linkedOrder.has(id)) linkedOrder.set(id, index);
|
||||||
|
});
|
||||||
|
|
||||||
|
return requirements
|
||||||
|
.filter((r) => linkedOrder.has(r.id))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const orderA = linkedOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const orderB = linkedOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
if (orderA !== orderB) return orderA - orderB;
|
||||||
|
return a.code.localeCompare(b.code, 'zh-CN');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function mergeSelectedRequirementOptions(
|
export function mergeSelectedRequirementOptions(
|
||||||
candidates: Requirement[],
|
candidates: Requirement[],
|
||||||
allRequirements: Requirement[],
|
allRequirements: Requirement[],
|
||||||
|
|||||||
Reference in New Issue
Block a user