feat: línea de tiempo mezclada en el detalle (Fase 2)
This commit is contained in:
1 parent
e15dc3926e
commit
bbb5fd2b1a
6 files changed
+307
-32
No files matched your search
@@ -4,13 +4,13 @@ import Link from "next/link";
|
|||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { Comment, Issue, IssueStatus } from "@/lib/types";
|
import type { Activity, Comment, Issue, IssueStatus } from "@/lib/types";
|
||||||
import { PRIORITY_META, TYPE_META } from "@/lib/meta";
|
import { PRIORITY_META, TYPE_META } from "@/lib/meta";
|
||||||
import { formatLongDate, formatRelative } from "@/lib/format";
|
import { formatLongDate, formatRelative } from "@/lib/format";
|
||||||
import { useReference, useReferenceLookups, useWorkflowStates } from "@/lib/store";
|
import { useReference, useReferenceLookups, useWorkflowStates } from "@/lib/store";
|
||||||
import { useAuth } from "@/lib/auth";
|
import { useAuth } from "@/lib/auth";
|
||||||
import { AppShell } from "@/components/layout/AppShell";
|
import { AppShell } from "@/components/layout/AppShell";
|
||||||
import { Avatar } from "@/components/ui/Avatar";
|
import { ActivityFeed } from "@/components/issues/ActivityFeed";
|
||||||
import { EmptyState } from "@/components/ui/EmptyState";
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
import { TypeIcon } from "@/components/ui/Icons";
|
import { TypeIcon } from "@/components/ui/Icons";
|
||||||
|
|
||||||
@@ -36,22 +36,25 @@ function IssueDetail({ issueId }: { issueId: string }) {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { meta, order } = useWorkflowStates();
|
const { meta, order } = useWorkflowStates();
|
||||||
const { users, projects, cycles, labels } = useReference();
|
const { users, projects, cycles, labels } = useReference();
|
||||||
const { projectById, userById } = useReferenceLookups();
|
const { projectById } = useReferenceLookups();
|
||||||
|
|
||||||
const [issue, setIssue] = useState<Issue | null>(null);
|
const [issue, setIssue] = useState<Issue | null>(null);
|
||||||
const [comments, setComments] = useState<Comment[]>([]);
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
|
const [activity, setActivity] = useState<Activity[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const [found, issueComments] = await Promise.all([
|
const [found, issueComments, issueActivity] = await Promise.all([
|
||||||
api.getIssue(issueId),
|
api.getIssue(issueId),
|
||||||
api.listComments(issueId),
|
api.listComments(issueId),
|
||||||
|
api.listActivity(issueId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setIssue(found);
|
setIssue(found);
|
||||||
setComments(issueComments);
|
setComments(issueComments);
|
||||||
|
setActivity(issueActivity);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [issueId]);
|
}, [issueId]);
|
||||||
|
|
||||||
@@ -67,8 +70,13 @@ function IssueDetail({ issueId }: { issueId: string }) {
|
|||||||
setIssue((current) => (current ? { ...current, ...partial } : current));
|
setIssue((current) => (current ? { ...current, ...partial } : current));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await api.updateIssue(issue.id, partial);
|
const updated = await api.updateIssue(issue.id, {
|
||||||
|
...partial,
|
||||||
|
...(user ? { actorId: user.id } : {}),
|
||||||
|
});
|
||||||
setIssue(updated);
|
setIssue(updated);
|
||||||
|
// Refrescar la línea de tiempo con los eventos recién creados.
|
||||||
|
void api.listActivity(issue.id).then(setActivity);
|
||||||
} catch {
|
} catch {
|
||||||
// Revertir si el backend rechaza el cambio.
|
// Revertir si el backend rechaza el cambio.
|
||||||
void load();
|
void load();
|
||||||
@@ -84,6 +92,7 @@ function IssueDetail({ issueId }: { issueId: string }) {
|
|||||||
setDraft("");
|
setDraft("");
|
||||||
const created = await api.addComment(issueId, body, user.id);
|
const created = await api.addComment(issueId, body, user.id);
|
||||||
setComments((current) => [...current, created]);
|
setComments((current) => [...current, created]);
|
||||||
|
void api.listActivity(issueId).then(setActivity);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -150,35 +159,21 @@ function IssueDetail({ issueId }: { issueId: string }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Comentarios */}
|
{/* Línea de tiempo: comentarios + eventos del log (Fase 2) */}
|
||||||
<section>
|
<section>
|
||||||
<div className="detail-section-title">Actividad</div>
|
<div className="detail-section-title">Actividad</div>
|
||||||
|
|
||||||
{comments.length === 0 && (
|
<ActivityFeed
|
||||||
<p style={{ color: "var(--lp-text-faint)", fontSize: 13 }}>
|
comments={comments}
|
||||||
Sin comentarios todavía.
|
activity={activity}
|
||||||
</p>
|
users={new Map(users.map((u) => [u.id, u]))}
|
||||||
)}
|
states={Object.fromEntries(
|
||||||
|
order.map((key) => [key, { label: meta[key]?.label ?? key }]),
|
||||||
{comments.map((comment) => {
|
)}
|
||||||
const author = userById.get(comment.authorId);
|
projects={new Map(projects.map((p) => [p.id, p]))}
|
||||||
return (
|
cycles={new Map(cycles.map((c) => [c.id, c]))}
|
||||||
<div key={comment.id} className="comment">
|
labels={new Map(labels.map((l) => [l.id, l]))}
|
||||||
<Avatar user={author} size="lg" />
|
/>
|
||||||
<div className="flex-grow-1">
|
|
||||||
<div className="d-flex align-items-baseline gap-2 mb-1">
|
|
||||||
<span style={{ fontSize: 13, fontWeight: 600 }}>
|
|
||||||
{author?.name ?? "Desconocido"}
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: 12, color: "var(--lp-text-faint)" }}>
|
|
||||||
{formatRelative(comment.createdAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="comment-body">{comment.body}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<form onSubmit={handleComment} className="mt-3">
|
<form onSubmit={handleComment} className="mt-3">
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import type { Activity, Comment, Cycle, Label, Project, User } from "@/lib/types";
|
||||||
|
import { formatRelative } from "@/lib/format";
|
||||||
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
|
|
||||||
|
interface ActivityFeedProps {
|
||||||
|
comments: Comment[];
|
||||||
|
activity: Activity[];
|
||||||
|
users: Map<string, User>;
|
||||||
|
states: Record<string, { label: string }>;
|
||||||
|
projects: Map<string, Project>;
|
||||||
|
cycles: Map<string, Cycle>;
|
||||||
|
labels: Map<string, Label>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeedItem =
|
||||||
|
| { kind: "comment"; at: string; comment: Comment }
|
||||||
|
| { kind: "event"; at: string; event: Activity };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Línea de tiempo mezclada de un issue (Fase 2): comentarios y eventos
|
||||||
|
* del log en orden cronológico, con descripciones legibles.
|
||||||
|
*
|
||||||
|
* Los valores guardados son claves/ids; aquí se resuelven a nombres
|
||||||
|
* usando los datos de referencia (usuarios, estados, proyectos...).
|
||||||
|
*/
|
||||||
|
export function ActivityFeed({ comments, activity, users, states, projects, cycles, labels }: ActivityFeedProps) {
|
||||||
|
const items = useMemo<FeedItem[]>(() => {
|
||||||
|
const list: FeedItem[] = [
|
||||||
|
...comments.map((comment) => ({ kind: "comment" as const, at: comment.createdAt, comment })),
|
||||||
|
...activity
|
||||||
|
// Los comentarios ya se muestran como tales; no duplicarlos.
|
||||||
|
.filter((event) => event.action !== "comment_added")
|
||||||
|
.map((event) => ({ kind: "event" as const, at: event.createdAt, event })),
|
||||||
|
];
|
||||||
|
return list.sort((a, b) => a.at.localeCompare(b.at));
|
||||||
|
}, [comments, activity]);
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<p style={{ color: "var(--inv-muted)", fontSize: 13 }}>
|
||||||
|
Sin actividad todavía.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{items.map((item) =>
|
||||||
|
item.kind === "comment" ? (
|
||||||
|
<CommentRow
|
||||||
|
key={`c-${item.comment.id}`}
|
||||||
|
comment={item.comment}
|
||||||
|
author={users.get(item.comment.authorId)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EventRow
|
||||||
|
key={`e-${item.event.id}`}
|
||||||
|
event={item.event}
|
||||||
|
actor={item.event.actorId ? users.get(item.event.actorId) : undefined}
|
||||||
|
states={states}
|
||||||
|
projects={projects}
|
||||||
|
cycles={cycles}
|
||||||
|
labels={labels}
|
||||||
|
users={users}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommentRow({ comment, author }: { comment: Comment; author?: User }) {
|
||||||
|
return (
|
||||||
|
<div className="comment">
|
||||||
|
<Avatar user={author} size="lg" />
|
||||||
|
<div className="flex-grow-1">
|
||||||
|
<div className="d-flex align-items-baseline gap-2 mb-1">
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600 }}>{author?.name ?? "Desconocido"}</span>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--inv-muted)" }}>
|
||||||
|
{formatRelative(comment.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="comment-body">{comment.body}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nombre legible de un valor según la acción (id → nombre cuando aplica). */
|
||||||
|
function resolveValue(
|
||||||
|
action: string,
|
||||||
|
value: string | null,
|
||||||
|
ctx: {
|
||||||
|
states: Record<string, { label: string }>;
|
||||||
|
projects: Map<string, Project>;
|
||||||
|
cycles: Map<string, Cycle>;
|
||||||
|
labels: Map<string, Label>;
|
||||||
|
users: Map<string, User>;
|
||||||
|
},
|
||||||
|
): string {
|
||||||
|
if (value == null || value === "") return "—";
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case "status":
|
||||||
|
return ctx.states[value]?.label ?? value;
|
||||||
|
case "assignee":
|
||||||
|
return ctx.users.get(value)?.name ?? value;
|
||||||
|
case "project":
|
||||||
|
return ctx.projects.get(value)?.name ?? value;
|
||||||
|
case "cycle":
|
||||||
|
return ctx.cycles.get(value)?.name ?? value;
|
||||||
|
case "labels":
|
||||||
|
if (value === "") return "ninguna";
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((id) => ctx.labels.get(id)?.name ?? id)
|
||||||
|
.join(", ");
|
||||||
|
case "priority": {
|
||||||
|
const names: Record<string, string> = {
|
||||||
|
"0": "sin prioridad",
|
||||||
|
"1": "baja",
|
||||||
|
"2": "media",
|
||||||
|
"3": "alta",
|
||||||
|
"4": "urgente",
|
||||||
|
};
|
||||||
|
return names[value] ?? value;
|
||||||
|
}
|
||||||
|
case "parent":
|
||||||
|
return value === "" ? "raíz" : `issue #${value}`;
|
||||||
|
default:
|
||||||
|
return value.length > 120 ? `${value.slice(0, 120)}…` : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_VERB: Record<string, string> = {
|
||||||
|
created: "creó el issue",
|
||||||
|
status: "cambió el estado",
|
||||||
|
assignee: "reasignó el issue",
|
||||||
|
priority: "cambió la prioridad",
|
||||||
|
type: "cambió el tipo",
|
||||||
|
title: "renombró el issue",
|
||||||
|
description: "editó la descripción",
|
||||||
|
project: "movió el issue de proyecto",
|
||||||
|
cycle: "movió el issue de ciclo",
|
||||||
|
labels: "actualizó las etiquetas",
|
||||||
|
parent: "movió el issue en la jerarquía",
|
||||||
|
relation_added: "vinculó el issue",
|
||||||
|
relation_removed: "quitó un vínculo",
|
||||||
|
subscribed: "se suscribió",
|
||||||
|
unsubscribed: "dejó de seguir",
|
||||||
|
};
|
||||||
|
|
||||||
|
function EventRow({
|
||||||
|
event,
|
||||||
|
actor,
|
||||||
|
states,
|
||||||
|
projects,
|
||||||
|
cycles,
|
||||||
|
labels,
|
||||||
|
users,
|
||||||
|
}: {
|
||||||
|
event: Activity;
|
||||||
|
actor?: User;
|
||||||
|
states: Record<string, { label: string }>;
|
||||||
|
projects: Map<string, Project>;
|
||||||
|
cycles: Map<string, Cycle>;
|
||||||
|
labels: Map<string, Label>;
|
||||||
|
users: Map<string, User>;
|
||||||
|
}) {
|
||||||
|
const ctx = { states, projects, cycles, labels, users };
|
||||||
|
const verb = ACTION_VERB[event.action] ?? event.action;
|
||||||
|
|
||||||
|
// Acciones con antes/después; el resto muestra el valor nuevo o nada.
|
||||||
|
const hasTransition = event.oldValue != null || event.newValue != null;
|
||||||
|
const detail =
|
||||||
|
event.action === "created"
|
||||||
|
? ` (${event.newValue})`
|
||||||
|
: hasTransition
|
||||||
|
? `: ${resolveValue(event.action, event.oldValue, ctx)} → ${resolveValue(event.action, event.newValue, ctx)}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="comment" style={{ alignItems: "center" }}>
|
||||||
|
<Avatar user={actor} />
|
||||||
|
<div className="flex-grow-1" style={{ fontSize: 13, color: "var(--inv-muted)" }}>
|
||||||
|
<span style={{ fontWeight: 600, color: "var(--bs-body-color)" }}>
|
||||||
|
{actor?.name ?? "Sistema"}
|
||||||
|
</span>{" "}
|
||||||
|
{verb}
|
||||||
|
{detail}
|
||||||
|
<span style={{ fontSize: 12, color: "var(--inv-muted)" }}>
|
||||||
|
{" "}
|
||||||
|
· {formatRelative(event.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
Activity,
|
||||||
Comment,
|
Comment,
|
||||||
Cycle,
|
Cycle,
|
||||||
ID,
|
ID,
|
||||||
@@ -41,6 +42,9 @@ export interface ProyectosApi {
|
|||||||
createIssue(input: IssueInput): Promise<Issue>;
|
createIssue(input: IssueInput): Promise<Issue>;
|
||||||
updateIssue(id: ID, patch: Partial<IssueInput>): Promise<Issue>;
|
updateIssue(id: ID, patch: Partial<IssueInput>): Promise<Issue>;
|
||||||
|
|
||||||
|
// Línea de tiempo (Fase 2 en sam): eventos de creación, cambios y comentarios.
|
||||||
|
listActivity(issueId: ID): Promise<Activity[]>;
|
||||||
|
|
||||||
// Comentarios
|
// Comentarios
|
||||||
listComments(issueId: ID): Promise<Comment[]>;
|
listComments(issueId: ID): Promise<Comment[]>;
|
||||||
/** `authorId` es el idUsuario del usuario en sesión. */
|
/** `authorId` es el idUsuario del usuario en sesión. */
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
Activity,
|
||||||
Comment,
|
Comment,
|
||||||
Cycle,
|
Cycle,
|
||||||
ID,
|
ID,
|
||||||
@@ -158,6 +159,8 @@ export const httpApi: ProyectosApi = {
|
|||||||
body: JSON.stringify(patch),
|
body: JSON.stringify(patch),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
listActivity: (issueId: ID) => request<Activity[]>(`/api/proyectos/issues/${issueId}/activity`),
|
||||||
|
|
||||||
listComments: (issueId: ID) => request<Comment[]>(`/api/proyectos/issues/${issueId}/comments`),
|
listComments: (issueId: ID) => request<Comment[]>(`/api/proyectos/issues/${issueId}/comments`),
|
||||||
|
|
||||||
addComment: (issueId: ID, body: string, authorId: ID) =>
|
addComment: (issueId: ID, body: string, authorId: ID) =>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
Activity,
|
||||||
Comment,
|
Comment,
|
||||||
Cycle,
|
Cycle,
|
||||||
ID,
|
ID,
|
||||||
@@ -25,6 +26,7 @@ import type { ProyectosApi } from "./client";
|
|||||||
const state = {
|
const state = {
|
||||||
issues: structuredClone(seed.issues),
|
issues: structuredClone(seed.issues),
|
||||||
comments: structuredClone(seed.comments),
|
comments: structuredClone(seed.comments),
|
||||||
|
activity: [] as Activity[],
|
||||||
projects: structuredClone(seed.projects),
|
projects: structuredClone(seed.projects),
|
||||||
cycles: structuredClone(seed.cycles),
|
cycles: structuredClone(seed.cycles),
|
||||||
users: structuredClone(seed.users),
|
users: structuredClone(seed.users),
|
||||||
@@ -34,6 +36,25 @@ const state = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Claves que cuentan como terminadas (bandera is_done de la API). */
|
/** Claves que cuentan como terminadas (bandera is_done de la API). */
|
||||||
|
/** Registra un evento en la línea de tiempo (réplica del backend). */
|
||||||
|
function logActivity(
|
||||||
|
issueId: ID,
|
||||||
|
actorId: ID | null,
|
||||||
|
action: string,
|
||||||
|
oldValue: string | null,
|
||||||
|
newValue: string | null,
|
||||||
|
) {
|
||||||
|
state.activity.push({
|
||||||
|
id: nextId("ev"),
|
||||||
|
issueId,
|
||||||
|
actorId,
|
||||||
|
action,
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function doneKeys(): Set<string> {
|
function doneKeys(): Set<string> {
|
||||||
return new Set(state.states.filter((s) => s.isDone).map((s) => s.key));
|
return new Set(state.states.filter((s) => s.isDone).map((s) => s.key));
|
||||||
}
|
}
|
||||||
@@ -203,6 +224,7 @@ export const mockApi: ProyectosApi = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
state.issues.unshift(issue);
|
state.issues.unshift(issue);
|
||||||
|
logActivity(issue.id, null, "created", null, issue.identifier);
|
||||||
return issue;
|
return issue;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -211,6 +233,26 @@ export const mockApi: ProyectosApi = {
|
|||||||
const issue = state.issues.find((i) => i.id === id);
|
const issue = state.issues.find((i) => i.id === id);
|
||||||
if (!issue) throw new Error(`Issue ${id} no encontrado`);
|
if (!issue) throw new Error(`Issue ${id} no encontrado`);
|
||||||
|
|
||||||
|
// Solo se registra lo que cambió de verdad (igual que el backend).
|
||||||
|
const actor = (patch as { actorId?: ID }).actorId ?? null;
|
||||||
|
const changed: Array<[string, string | null, string | null]> = [];
|
||||||
|
const track = (action: string, oldV: unknown, newV: unknown) => {
|
||||||
|
const o = oldV == null ? null : String(oldV);
|
||||||
|
const n = newV == null ? null : String(newV);
|
||||||
|
if (o !== n) changed.push([action, o, n]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (patch.title !== undefined) track("title", issue.title, patch.title);
|
||||||
|
if (patch.description !== undefined) track("description", issue.description, patch.description);
|
||||||
|
if (patch.status !== undefined) track("status", issue.status, patch.status);
|
||||||
|
if (patch.type !== undefined) track("type", issue.type, patch.type);
|
||||||
|
if (patch.priority !== undefined) track("priority", issue.priority, patch.priority);
|
||||||
|
if (patch.assigneeId !== undefined) track("assignee", issue.assigneeId, patch.assigneeId);
|
||||||
|
if (patch.projectId !== undefined) track("project", issue.projectId, patch.projectId);
|
||||||
|
if (patch.cycleId !== undefined) track("cycle", issue.cycleId, patch.cycleId);
|
||||||
|
if (patch.labelIds !== undefined)
|
||||||
|
track("labels", issue.labelIds.join(","), patch.labelIds.join(","));
|
||||||
|
|
||||||
Object.assign(issue, patch, { updatedAt: new Date().toISOString() });
|
Object.assign(issue, patch, { updatedAt: new Date().toISOString() });
|
||||||
|
|
||||||
// Mantener `completedAt` coherente con el estado final
|
// Mantener `completedAt` coherente con el estado final
|
||||||
@@ -218,9 +260,17 @@ export const mockApi: ProyectosApi = {
|
|||||||
const isFinished = doneKeys().has(issue.status);
|
const isFinished = doneKeys().has(issue.status);
|
||||||
issue.completedAt = isFinished ? (issue.completedAt ?? new Date().toISOString()) : null;
|
issue.completedAt = isFinished ? (issue.completedAt ?? new Date().toISOString()) : null;
|
||||||
|
|
||||||
|
for (const [action, o, n] of changed) logActivity(id, actor, action, o, n);
|
||||||
return issue;
|
return issue;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listActivity(issueId: ID): Promise<Activity[]> {
|
||||||
|
await delay(60);
|
||||||
|
return state.activity
|
||||||
|
.filter((e) => e.issueId === issueId)
|
||||||
|
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||||
|
},
|
||||||
|
|
||||||
async listComments(issueId: ID): Promise<Comment[]> {
|
async listComments(issueId: ID): Promise<Comment[]> {
|
||||||
await delay(60);
|
await delay(60);
|
||||||
return state.comments.filter((c) => c.issueId === issueId);
|
return state.comments.filter((c) => c.issueId === issueId);
|
||||||
@@ -236,6 +286,7 @@ export const mockApi: ProyectosApi = {
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
state.comments.push(comment);
|
state.comments.push(comment);
|
||||||
|
logActivity(issueId, authorId, "comment_added", null, body.slice(0, 200));
|
||||||
return comment;
|
return comment;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
+22
-1
@@ -82,6 +82,22 @@ export interface Cycle {
|
|||||||
status: "upcoming" | "active" | "completed";
|
status: "upcoming" | "active" | "completed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evento del log de actividad (Fase 2 en sam).
|
||||||
|
* action: created, status, assignee, priority, type, title, description,
|
||||||
|
* project, cycle, labels, comment_added, parent, relation_added,
|
||||||
|
* relation_removed, subscribed, unsubscribed.
|
||||||
|
*/
|
||||||
|
export interface Activity {
|
||||||
|
id: ID;
|
||||||
|
issueId: ID;
|
||||||
|
actorId: ID | null;
|
||||||
|
action: string;
|
||||||
|
oldValue: string | null;
|
||||||
|
newValue: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Comment {
|
export interface Comment {
|
||||||
id: ID;
|
id: ID;
|
||||||
issueId: ID;
|
issueId: ID;
|
||||||
@@ -119,7 +135,11 @@ export interface IssueFilters {
|
|||||||
query?: string;
|
query?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload para crear/actualizar un issue. */
|
/**
|
||||||
|
* Payload para crear/actualizar un issue.
|
||||||
|
* actorId es transitorio: el backend lo usa como autor del log y no lo
|
||||||
|
* persiste en el issue (ver BuildPatch en sam).
|
||||||
|
*/
|
||||||
export interface IssueInput {
|
export interface IssueInput {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -130,4 +150,5 @@ export interface IssueInput {
|
|||||||
projectId?: ID | null;
|
projectId?: ID | null;
|
||||||
cycleId?: ID | null;
|
cycleId?: ID | null;
|
||||||
labelIds?: ID[];
|
labelIds?: ID[];
|
||||||
|
actorId?: ID;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user