mirror of
https://gh-proxy.org/https://github.com/klinsmaya/portal-template-default.git
synced 2026-11-08 00:57:57 +08:00
Merge pull request #6 from klinsmaya/claude/project-thread-liggvu
修复用户反馈:保存误报、改一下可取消、成员自动带部门、S1-02 删关系性能
This commit is contained in:
@@ -172,6 +172,14 @@ async function upsertMembers(ctx: Context, projectId: number, spaceName: string,
|
||||
for (const m of list) {
|
||||
const existing = await repo.findOne({ filter: { projectId, userId: m.userId }, transaction });
|
||||
added.push({ userId: m.userId, projectRole: m.projectRole, isNew: !existing });
|
||||
// A new member with no department takes the one they already have in the enterprise's other projects.
|
||||
if (!existing && !m.orgUnitId) {
|
||||
const known = await repo.find({ filter: { userId: m.userId, spaceName, orgUnitId: { $notEmpty: true } }, transaction });
|
||||
const counts = new Map<number, number>();
|
||||
for (const k of known) counts.set(k.get('orgUnitId'), (counts.get(k.get('orgUnitId')) ?? 0) + 1);
|
||||
const best = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
||||
if (best) m.orgUnitId = best;
|
||||
}
|
||||
if (existing) {
|
||||
await existing.update({ projectRole: m.projectRole, orgUnitId: m.orgUnitId ?? null }, { transaction });
|
||||
} else {
|
||||
|
||||
@@ -27,3 +27,20 @@ export function memberWarnings(rows: MemberRow[], service: "guided" | "self" = "
|
||||
if (new Set(ids).size !== ids.length) warnings.push("同一个人出现了多次,保存时以最后一行为准。");
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* The department a person is placed in when added to a project: the one they already have in the
|
||||
* enterprise's other projects (the most common one), else the department they head.
|
||||
*/
|
||||
export function defaultOrgUnitId(
|
||||
userId: number,
|
||||
otherProjects: { members: { userId: number; orgUnitId: number | null }[] }[],
|
||||
orgUnits: { id: number; headId?: number | null }[],
|
||||
): number | null {
|
||||
const counts = new Map<number, number>();
|
||||
for (const project of otherProjects)
|
||||
for (const m of project.members) if (m.userId === userId && m.orgUnitId) counts.set(m.orgUnitId, (counts.get(m.orgUnitId) ?? 0) + 1);
|
||||
const known = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
||||
if (known && orgUnits.some((u) => u.id === known)) return known;
|
||||
return orgUnits.find((u) => u.headId === userId)?.id ?? null;
|
||||
}
|
||||
|
||||
@@ -89,11 +89,20 @@ function useInvalidateProject(project: ProjectSummary | undefined) {
|
||||
};
|
||||
}
|
||||
|
||||
export function useSaveArtifact(project: ProjectSummary | undefined, code: string) {
|
||||
type SaveValues = { payload: unknown; baseRev: number; note?: string; fromProposalId?: number; aiSuggested?: boolean; changes?: unknown[] };
|
||||
|
||||
/**
|
||||
* `onSaved` runs the moment the server accepts the save, before the artifact is refetched: the page
|
||||
* must learn the new revision first, or the refetch would look like somebody else's change.
|
||||
*/
|
||||
export function useSaveArtifact(project: ProjectSummary | undefined, code: string, onSaved?: (result: { rev: number }, values: SaveValues) => void) {
|
||||
const invalidate = useInvalidateProject(project);
|
||||
return useMutation({
|
||||
mutationFn: (values: { payload: unknown; baseRev: number; note?: string; fromProposalId?: number; aiSuggested?: boolean; changes?: unknown[] }) =>
|
||||
saveArtifact(project!.spaceName, { projectId: project!.id, code, ...values }),
|
||||
mutationFn: async (values: SaveValues) => {
|
||||
const result = await saveArtifact(project!.spaceName, { projectId: project!.id, code, ...values });
|
||||
onSaved?.(result, values);
|
||||
return result;
|
||||
},
|
||||
onSuccess: () => invalidate(code),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// jsonb in the database does not keep key order, so a saved payload comes back with the same data in a
|
||||
// different key order. Comparing with plain JSON.stringify then reports “未保存” forever after a save.
|
||||
|
||||
function sortKeys(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(sortKeys);
|
||||
if (value && typeof value === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(value as Record<string, unknown>).sort()) out[key] = sortKeys((value as Record<string, unknown>)[key]);
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** JSON text that does not depend on key order (undefined fields are dropped, as JSON.stringify does). */
|
||||
export function stableStringify(value: unknown): string {
|
||||
return JSON.stringify(sortKeys(value)) ?? "undefined";
|
||||
}
|
||||
|
||||
/** Same data, whatever the key order. */
|
||||
export function samePayload(a: unknown, b: unknown): boolean {
|
||||
return a === b || stableStringify(a) === stableStringify(b);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import { errorMessage } from "@/lib/dingze/errors";
|
||||
import { type EnterpriseDetail, type OpsProject, opsApi } from "@/lib/dingze/ops-api";
|
||||
import { useOpsMutation } from "@/lib/dingze/ops-queries";
|
||||
import { flattenOrgUnits, orgUnitPath } from "@/lib/dingze/org-units";
|
||||
import { type MemberRow, diffMembers, memberWarnings } from "@/lib/dingze/project-members";
|
||||
import { type MemberRow, defaultOrgUnitId, diffMembers, memberWarnings } from "@/lib/dingze/project-members";
|
||||
|
||||
import { SCENE_LABELS, SCHEDULE_LABELS } from "./labels";
|
||||
|
||||
@@ -340,7 +340,12 @@ function MembersDialog({
|
||||
const addRow = (userId: number) =>
|
||||
setRows([
|
||||
...rows,
|
||||
{ userId, projectRole: isConsultantAccount(userId) ? "co_consultant" : "member", orgUnitId: null },
|
||||
{
|
||||
userId,
|
||||
projectRole: isConsultantAccount(userId) ? "co_consultant" : "member",
|
||||
// 自动带上所属部门:本企业其他项目里已有的部门,或其负责的部门。
|
||||
orgUnitId: isConsultantAccount(userId) ? null : defaultOrgUnitId(userId, detail.projects, detail.orgUnits),
|
||||
},
|
||||
]);
|
||||
|
||||
const save = async () => {
|
||||
@@ -596,7 +601,7 @@ function OwnProjectDialog({ open, detail, onClose }: { open: boolean; detail: En
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<NativeSelect aria-label="添加成员" value="" onChange={(e) => e.target.value && setRows([...rows, { userId: Number(e.target.value), projectRole: "member", orgUnitId: null }])} disabled={available.length === 0}>
|
||||
<NativeSelect aria-label="添加成员" value="" onChange={(e) => e.target.value && setRows([...rows, { userId: Number(e.target.value), projectRole: "member", orgUnitId: defaultOrgUnitId(Number(e.target.value), detail.projects, detail.orgUnits) }])} disabled={available.length === 0}>
|
||||
<NativeSelectOption value="">{available.length ? "+ 添加本企业成员…" : "本企业的账号都已加入"}</NativeSelectOption>
|
||||
{available.map((m) => (
|
||||
<NativeSelectOption key={m.id} value={m.id}>
|
||||
|
||||
@@ -246,6 +246,8 @@ function PendingCard({
|
||||
setDecided(true);
|
||||
fn();
|
||||
};
|
||||
// 改一下 is not a decision: the card stays open, can be cancelled, and 确认修改 stays available.
|
||||
const [revising, setRevising] = useState(false);
|
||||
const key = JSON.stringify(built?.items ?? []);
|
||||
useEffect(() => {
|
||||
if (!session || !built || built.invalid) return;
|
||||
@@ -361,8 +363,18 @@ function PendingCard({
|
||||
<Button size="sm" disabled={disabled || decided || !!built?.invalid} onClick={decide(() => onApprove())}>
|
||||
<Check /> 确认修改
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={disabled || decided} onClick={decide(() => onRevise())}>
|
||||
<PencilLine /> 改一下
|
||||
<Button
|
||||
size="sm"
|
||||
variant={revising ? "secondary" : "outline"}
|
||||
disabled={disabled || decided}
|
||||
aria-pressed={revising}
|
||||
onClick={() => {
|
||||
if (revising) return setRevising(false);
|
||||
setRevising(true);
|
||||
onRevise();
|
||||
}}
|
||||
>
|
||||
<PencilLine /> {revising ? "取消改一下" : "改一下"}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" disabled={disabled || decided} onClick={decide(() => onReject("用户不采纳这条建议"))}>
|
||||
不采纳
|
||||
@@ -373,6 +385,11 @@ function PendingCard({
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{revising ? (
|
||||
<p className="text-[11px]" style={{ color: "var(--dz-ai)" }} role="status">
|
||||
正在改这条建议:请在下面的对话框说明怎么改,数字咨询师会重出修改卡。不改了就点“取消改一下”,仍可直接“确认修改”。
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-[11px] text-muted-foreground">确认后写入草稿、记入“修改”,可撤销;保存后生效。</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Network, Table2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { memo, useCallback, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
type Issue,
|
||||
@@ -39,8 +39,26 @@ export function StrategyMapEditor({ value, onChange, readOnly, issues, upstream
|
||||
const links = value.links ?? [];
|
||||
const seed = upstream["S1-01"] ? seedStrategyMap(upstream["S1-01"]) : null;
|
||||
|
||||
// Row callbacks read the latest value through a ref: they stay the same function between renders (so the
|
||||
// relation rows can be memoized — a long list of selects made every delete slow) and two quick clicks never
|
||||
// work from a stale list.
|
||||
const latest = useRef({ value, onChange });
|
||||
latest.current = { value, onChange };
|
||||
const setObjectives = (next: MapObjective[]) => onChange({ ...value, objectives: next });
|
||||
const setLinks = (next: MapLink[]) => onChange({ ...value, links: next });
|
||||
const patchLink = useCallback((id: string, patch: Partial<MapLink>) => {
|
||||
const { value: v, onChange: change } = latest.current;
|
||||
change({ ...v, links: patchById(v.links ?? [], id, patch) });
|
||||
}, []);
|
||||
const removeLink = useCallback((id: string) => {
|
||||
const { value: v, onChange: change } = latest.current;
|
||||
change({ ...v, links: removeById(v.links ?? [], id) });
|
||||
}, []);
|
||||
const moveLink = useCallback((id: string, delta: -1 | 1) => {
|
||||
const { value: v, onChange: change } = latest.current;
|
||||
change({ ...v, links: moveById(v.links ?? [], id, delta) });
|
||||
}, []);
|
||||
const groups = useMemo(() => PERSPECTIVES.map((p) => ({ key: p.key, label: p.label, items: objectives.filter((o) => o.perspective === p.key) })), [objectives]);
|
||||
const removeObjective = (id: string) =>
|
||||
onChange({ objectives: removeById(objectives, id), links: links.filter((l) => l.from !== id && l.to !== id) });
|
||||
|
||||
@@ -129,30 +147,7 @@ export function StrategyMapEditor({ value, onChange, readOnly, issues, upstream
|
||||
) : (
|
||||
<ul className="overflow-hidden rounded-xl border bg-card">
|
||||
{links.map((link) => (
|
||||
<li
|
||||
key={link.id}
|
||||
data-anchor={link.id}
|
||||
className={cn(
|
||||
"grid items-center gap-2 border-b px-3 py-2 last:border-b-0 md:grid-cols-[1fr_auto_1fr_auto]",
|
||||
invalid.has(link.id) && "bg-destructive/5"
|
||||
)}
|
||||
>
|
||||
<ObjectiveSelect label="起点目标" objectives={objectives} value={link.from} disabled={readOnly} onChange={(from) => setLinks(patchById(links, link.id, { from }))} />
|
||||
<NativeSelect
|
||||
aria-label="关系类型"
|
||||
value={link.kind}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setLinks(patchById(links, link.id, { kind: e.target.value as MapLink["kind"] }))}
|
||||
>
|
||||
{Object.entries(KIND_LABELS).map(([k, label]) => (
|
||||
<NativeSelectOption key={k} value={k}>
|
||||
{label} →
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<ObjectiveSelect label="终点目标" objectives={objectives} value={link.to} disabled={readOnly} onChange={(to) => setLinks(patchById(links, link.id, { to }))} />
|
||||
<RowActions label="这条关系" readOnly={readOnly} onRemove={() => setLinks(removeById(links, link.id))} onMove={(delta) => setLinks(moveById(links, link.id, delta))} />
|
||||
</li>
|
||||
<LinkRow key={link.id} link={link} groups={groups} invalid={invalid.has(link.id)} readOnly={readOnly} onPatch={patchLink} onRemove={removeLink} onMove={moveLink} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
@@ -163,31 +158,52 @@ export function StrategyMapEditor({ value, onChange, readOnly, issues, upstream
|
||||
);
|
||||
}
|
||||
|
||||
function ObjectiveSelect({
|
||||
label,
|
||||
objectives,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
type Group = { key: string; label: string; items: MapObjective[] };
|
||||
|
||||
const LinkRow = memo(function LinkRow({
|
||||
link,
|
||||
groups,
|
||||
invalid,
|
||||
readOnly,
|
||||
onPatch,
|
||||
onRemove,
|
||||
onMove,
|
||||
}: {
|
||||
label: string;
|
||||
objectives: MapObjective[];
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
disabled: boolean;
|
||||
link: MapLink;
|
||||
groups: Group[];
|
||||
invalid: boolean;
|
||||
readOnly: boolean;
|
||||
onPatch: (id: string, patch: Partial<MapLink>) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onMove: (id: string, delta: -1 | 1) => void;
|
||||
}) {
|
||||
return (
|
||||
<li data-anchor={link.id} className={cn("grid items-center gap-2 border-b px-3 py-2 last:border-b-0 md:grid-cols-[1fr_auto_1fr_auto]", invalid && "bg-destructive/5")}>
|
||||
<ObjectiveSelect label="起点目标" groups={groups} value={link.from} disabled={readOnly} onChange={(from) => onPatch(link.id, { from })} />
|
||||
<NativeSelect aria-label="关系类型" value={link.kind} disabled={readOnly} onChange={(e) => onPatch(link.id, { kind: e.target.value as MapLink["kind"] })}>
|
||||
{Object.entries(KIND_LABELS).map(([k, label]) => (
|
||||
<NativeSelectOption key={k} value={k}>
|
||||
{label} →
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<ObjectiveSelect label="终点目标" groups={groups} value={link.to} disabled={readOnly} onChange={(to) => onPatch(link.id, { to })} />
|
||||
<RowActions label="这条关系" readOnly={readOnly} onRemove={() => onRemove(link.id)} onMove={(delta) => onMove(link.id, delta)} />
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
function ObjectiveSelect({ label, groups, value, onChange, disabled }: { label: string; groups: Group[]; value: string; onChange: (id: string) => void; disabled: boolean }) {
|
||||
return (
|
||||
<NativeSelect aria-label={label} className="w-full" value={value} disabled={disabled} onChange={(e) => onChange(e.target.value)}>
|
||||
<NativeSelectOption value="">选择目标…</NativeSelectOption>
|
||||
{PERSPECTIVES.map((p) => (
|
||||
<NativeSelectOptGroup key={p.key} label={p.label}>
|
||||
{objectives
|
||||
.filter((o) => o.perspective === p.key)
|
||||
.map((o) => (
|
||||
<NativeSelectOption key={o.id} value={o.id}>
|
||||
{o.title || "(未命名)"}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
{groups.map((g) => (
|
||||
<NativeSelectOptGroup key={g.key} label={g.label}>
|
||||
{g.items.map((o) => (
|
||||
<NativeSelectOption key={o.id} value={o.id}>
|
||||
{o.title || "(未命名)"}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelectOptGroup>
|
||||
))}
|
||||
</NativeSelect>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { samePayload } from "@/lib/dingze/stable-json";
|
||||
import { CheckCheck, ClipboardPaste, Download, Eye, FileCheck2, FileUp, Save, ShieldCheck, Sparkles, Trash2 } from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useGetIdentity } from "@refinedev/core";
|
||||
@@ -277,7 +278,7 @@ export default function MaterialsPage() {
|
||||
const [items, setItems] = useState<ProfileItem[]>([]);
|
||||
const [baseRev, setBaseRev] = useState(0);
|
||||
const serverItems = profile.data?.items;
|
||||
const dirty = JSON.stringify(items) !== JSON.stringify(serverItems ?? []);
|
||||
const dirty = !samePayload(items, serverItems ?? []);
|
||||
useEffect(() => {
|
||||
if (!profile.data) return;
|
||||
if (!dirty || profile.data.rev !== baseRev) {
|
||||
|
||||
@@ -50,6 +50,7 @@ import { EDITABLE_CODES, emptyPayload } from "@/lib/dingze/artifact-payloads";
|
||||
import { type ProposedChange, applyChanges } from "@/lib/dingze/changes";
|
||||
import { workflowContext } from "@/lib/dingze/coach-context";
|
||||
import { usePersistentFlag } from "@/lib/dingze/view-prefs";
|
||||
import { samePayload } from "@/lib/dingze/stable-json";
|
||||
import { PROPOSE_TOOL } from "@/lib/dingze/coach";
|
||||
import { errorMessage } from "@/lib/dingze/errors";
|
||||
|
||||
@@ -81,8 +82,9 @@ import { ArtifactEditor } from "./editors/artifact-editor";
|
||||
import { useProjectContext } from "./project-context";
|
||||
|
||||
type AsideTab = "coach" | "comments" | "changes" | "book";
|
||||
type EditState = { payload: unknown; log: Command[]; redo: Command[]; saved: Command[] };
|
||||
const freshEdit = (payload: unknown): EditState => ({ payload, log: [], redo: [], saved: [] });
|
||||
/** `base` is what the server has (after our own save: what we saved), so “未保存” is draft vs base. */
|
||||
type EditState = { payload: unknown; log: Command[]; redo: Command[]; saved: Command[]; base: unknown };
|
||||
const freshEdit = (payload: unknown): EditState => ({ payload, log: [], redo: [], saved: [], base: payload });
|
||||
|
||||
|
||||
const ENTERPRISE_ROLES = ["ent_lead", "dept_head", "member"];
|
||||
@@ -121,7 +123,21 @@ function Workspace({ code }: { code: string }) {
|
||||
const { project, overview, topbar } = useProjectContext();
|
||||
const def = getArtifactDef(code);
|
||||
const detail = useArtifactDetail(project, code);
|
||||
const save = useSaveArtifact(project, code);
|
||||
// Our own save: learn the new revision and what was saved before the refetch lands, so edits typed
|
||||
// meanwhile stay as unsaved and the refetch is not mistaken for someone else's change.
|
||||
const [baseRev, setBaseRev] = useState(0);
|
||||
const savingLog = useRef<Command[]>([]);
|
||||
const save = useSaveArtifact(project, code, (result, values) => {
|
||||
const done = new Set(savingLog.current.map((c) => c.id));
|
||||
setBaseRev(result.rev);
|
||||
setEdit((e) => ({
|
||||
...e,
|
||||
base: values.payload,
|
||||
log: e.log.filter((c) => !done.has(c.id)),
|
||||
redo: [],
|
||||
saved: [...e.saved, ...savingLog.current].slice(-50),
|
||||
}));
|
||||
});
|
||||
const liveUpstream = useUpstreamPayloads(project, [...def.dependsOn, ...(def.reads ?? [])]).payloads;
|
||||
|
||||
const artifact = detail.data?.artifact ?? null;
|
||||
@@ -136,8 +152,7 @@ function Workspace({ code }: { code: string }) {
|
||||
// commands, and commands saved in this visit can still be undone (as a new 撤销 command).
|
||||
const [edit, setEdit] = useState<EditState>(() => freshEdit(serverPayload));
|
||||
const draft = edit.payload;
|
||||
const [baseRev, setBaseRev] = useState(serverRev);
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(serverPayload);
|
||||
const dirty = useMemo(() => !samePayload(draft, edit.base), [draft, edit.base]);
|
||||
const [activeComment, setActiveComment] = useState<number | null>(null);
|
||||
const activeCommentRef = useRef(activeComment);
|
||||
activeCommentRef.current = activeComment;
|
||||
@@ -281,12 +296,11 @@ function Workspace({ code }: { code: string }) {
|
||||
const addComment = useAddComment(project, code);
|
||||
const onSave = () => {
|
||||
const log = edit.log;
|
||||
savingLog.current = log;
|
||||
save.mutate(
|
||||
{ payload: draft, baseRev, changes: log.map(toSaved), aiSuggested: log.some((c) => c.via === "chat") || undefined },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
setBaseRev(result.rev);
|
||||
setEdit((e) => ({ ...e, log: [], redo: [], saved: [...e.saved, ...log].slice(-50) }));
|
||||
toast.success(`已保存 v${result.rev}`);
|
||||
// Edits made 按批注 answer their comment thread.
|
||||
const byComment = new Map<number, string[]>();
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { GoalPathSystem, StrategyMap } from "@dingze/shared";
|
||||
|
||||
import { PathSystemEditor } from "@/pages/projects/editors/path-system-editor";
|
||||
import { StrategyMapEditor } from "@/pages/projects/editors/strategy-map-editor";
|
||||
|
||||
function PathHost({ initial, view }: { initial: GoalPathSystem; view: "t310" | "mind" }) {
|
||||
const [value, setValue] = useState(initial);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setValue({ ...value, nodes: value.nodes.map((n) => (n.id === "a" ? { ...n, value: "800", amount: 800 } : n)) })}>external fix</button>
|
||||
<PathSystemEditor
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
readOnly={false}
|
||||
issues={[]}
|
||||
goalWord="年度目标"
|
||||
goals={[{ id: "g1", label: "营收", metric: "营收", value: "1000", unit: "万元" }]}
|
||||
decodeLayout
|
||||
graphSpecId="S2-02"
|
||||
defaultView={view}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const paths = (): GoalPathSystem => ({
|
||||
nodes: [
|
||||
{ id: "a", parentId: null, goalId: "g1", level: 1, path: "开发新客户", metric: "营收", value: "1000", amount: 1000, unit: "万元", perspective: "financial" },
|
||||
{ id: "a1", parentId: "a", goalId: null, level: 2, path: "华东", metric: "营收", value: "300", amount: 300, unit: "万元" },
|
||||
{ id: "a2", parentId: "a", goalId: null, level: 2, path: "华南", metric: "营收", value: "500", amount: 500, unit: "万元" },
|
||||
],
|
||||
} as GoalPathSystem);
|
||||
|
||||
describe("one-click fix in 表 3-10 refreshes the sums", () => {
|
||||
it("updates the check row and the mind map right after the fix", () => {
|
||||
render(<PathHost initial={paths()} view="t310" />);
|
||||
const fix = screen.getByRole("button", { name: /把上级改为 800/ });
|
||||
fireEvent.click(fix);
|
||||
expect(screen.queryByRole("button", { name: /把上级改为 800/ })).toBeNull();
|
||||
expect(screen.getByText(/800 = 300 \+ 500/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("mind map sums follow an external change", () => {
|
||||
render(<PathHost initial={paths()} view="mind" />);
|
||||
expect(screen.getAllByText(/1000 ≠ 300 \+ 500|≠/).length).toBeGreaterThan(0);
|
||||
fireEvent.click(screen.getByText("external fix"));
|
||||
expect(screen.getAllByText(/800 = 300 \+ 500|300 \+ 500 = 800/).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
function MapHost({ initial }: { initial: StrategyMap }) {
|
||||
const [value, setValue] = useState(initial);
|
||||
return <StrategyMapEditor value={value} onChange={setValue} readOnly={false} issues={[]} upstream={{}} />;
|
||||
}
|
||||
|
||||
describe("strategy map relation removal", () => {
|
||||
it("removes the relation row at once", () => {
|
||||
render(
|
||||
<MapHost
|
||||
initial={{
|
||||
objectives: [
|
||||
{ id: "o1", perspective: "financial", title: "营收", note: "" },
|
||||
{ id: "o2", perspective: "customer", title: "客户", note: "" },
|
||||
],
|
||||
links: [{ id: "r1", from: "o2", to: "o1", kind: "cause" }],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "删除 这条关系" }));
|
||||
expect(screen.getByText("还没有关系")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { type MemberRow, diffMembers, memberWarnings } from "@/lib/dingze/project-members";
|
||||
import { type MemberRow, defaultOrgUnitId, diffMembers, memberWarnings } from "@/lib/dingze/project-members";
|
||||
|
||||
const row = (userId: number, projectRole: MemberRow["projectRole"], orgUnitId: number | null = null): MemberRow => ({
|
||||
userId,
|
||||
@@ -26,4 +26,12 @@ describe("project member editing", () => {
|
||||
]);
|
||||
expect(memberWarnings([row(1, "lead_consultant"), row(2, "ent_lead")])).toEqual([]);
|
||||
});
|
||||
|
||||
it("places a newly added member in the department they already have, else the one they head", () => {
|
||||
const other = [{ members: [{ userId: 7, orgUnitId: 5 }, { userId: 7, orgUnitId: 5 }, { userId: 7, orgUnitId: 6 }] }];
|
||||
expect(defaultOrgUnitId(7, other, [{ id: 5 }, { id: 6 }])).toBe(5);
|
||||
expect(defaultOrgUnitId(8, other, [{ id: 5, headId: 8 }])).toBe(5);
|
||||
expect(defaultOrgUnitId(9, other, [{ id: 5 }])).toBeNull();
|
||||
expect(defaultOrgUnitId(7, other, [{ id: 6 }])).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { samePayload } from "@/lib/dingze/stable-json";
|
||||
|
||||
describe("payload comparison", () => {
|
||||
it("ignores key order, as jsonb does not keep it", () => {
|
||||
expect(samePayload({ coreUser: { who: "a", need: "b" }, statement: "s" }, { statement: "s", coreUser: { need: "b", who: "a" } })).toBe(true);
|
||||
});
|
||||
it("still sees real changes, including array order", () => {
|
||||
expect(samePayload({ a: [1, 2] }, { a: [2, 1] })).toBe(false);
|
||||
expect(samePayload({ a: "x" }, { a: "y" })).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user