feat: per-project workflow settings, statistics and "my work" home
Per-project settings (owners/admins, «Configuración» tab):
- Statuses with category (TODO, IN_PROGRESS, QA, DONE, CANCELLED), colour and
"requires reason"; configurable transitions; ticket types (icon, colour) and
areas. Defaults are the previous fixed values; projects created before keep
them without migration.
- Values are archived, never deleted: tickets, history and statistics keep
referring to them. Validated in the domain (ProjectSettingsChangedEvent).
- Releases, board, tracking and "my work" use categories, so custom statuses
keep working. Status changes carry name/category metadata for history.
- GraphQL: status/type/area are strings (breaking for clients sending enum
literals); Project.settings, updateProjectSettings, statusCategory,
myIssues(categories). REST: PUT /projects/{project}/settings, category filters.
- Read model: statusCategory on issues (backfilled at startup).
Statistics («Estadísticas» tab):
- New issue_timeline projection; pure functions compute created vs resolved,
cumulative flow, cycle/lead time (avg + p85), throughput per person, QA
pass/fail and release burnup. GraphQL Project.analytics(days),
Release.burnup; REST /projects/{project}/analytics, /releases/{id}/burnup.
- Web: SVG charts with validated light/dark palette, crosshair tooltip,
keyboard navigation, legend and data table.
Home "Mi trabajo": my open tickets across projects grouped by category (QA
failures first), tickets waiting for QA, upcoming releases and "Ir a…".
Tests: domain/application specs, e2e scenario for settings and analytics,
facade tests. Docs, READMEs, schema.gql/openapi.json and Word/PDF updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,6 @@ import {
|
||||
ActivityGateway,
|
||||
CommentsGateway,
|
||||
IssuesGateway,
|
||||
IssueStatus,
|
||||
Project,
|
||||
ProjectRole,
|
||||
ProjectsGateway,
|
||||
@@ -19,12 +18,14 @@ import { boardPageEvents } from '../events/board.events';
|
||||
import { projectPageEvents } from '../events/project.events';
|
||||
import { Notifications } from '../notifications';
|
||||
import { BoardFacade, provideBoardFacade } from './board.facade';
|
||||
import { HomeFacade } from './home.facade';
|
||||
import { IssueFacade, provideIssueFacade } from './issue.facade';
|
||||
import { ProjectFacade, provideProjectFacade } from './project.facade';
|
||||
|
||||
const ok = { id: 'x', key: null, aggregateVersion: 1 };
|
||||
const project = { id: 'p1', key: 'WEB', members: [] } as unknown as Project;
|
||||
const issue = { id: 'i1', key: 'WEB-1', links: [] };
|
||||
const analytics = { from: 'a', to: 'b', qa: { failureRate: 0.25 } };
|
||||
|
||||
/** Every command succeeds, every query answers once, nothing arrives in real time. */
|
||||
function gateways() {
|
||||
@@ -33,6 +34,8 @@ function gateways() {
|
||||
project: vi.fn(() => of(project)),
|
||||
stats: vi.fn(() => NEVER),
|
||||
addMember: vi.fn(() => of(ok)),
|
||||
updateSettings: vi.fn(() => of(ok)),
|
||||
analytics: vi.fn(() => of(analytics)),
|
||||
},
|
||||
issues: {
|
||||
board: vi.fn(() => of([])),
|
||||
@@ -127,6 +130,28 @@ describe('ProjectFacade', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('saves settings and loads statistics of the open project', () => {
|
||||
const { project, fakes } = setup();
|
||||
project.open('WEB');
|
||||
const settings = {
|
||||
statuses: [],
|
||||
transitions: [],
|
||||
issueTypes: [],
|
||||
areas: [],
|
||||
};
|
||||
|
||||
project.saveSettings(settings);
|
||||
expect(fakes.projects.updateSettings).toHaveBeenCalledWith('p1', settings);
|
||||
expect(project.savingSettings()).toBe(false);
|
||||
|
||||
project.loadAnalytics(30);
|
||||
expect(fakes.projects.analytics).toHaveBeenCalledWith('p1', 30);
|
||||
expect(project.analyticsDays()).toBe(30);
|
||||
expect(project.analytics()).toBe(analytics);
|
||||
// Unknown ids still get a readable name.
|
||||
expect(project.workflow().status('IN_REVIEW').name).toBe('In review');
|
||||
});
|
||||
|
||||
it('searches the directory from two characters', () => {
|
||||
const { project } = setup();
|
||||
const seen: string[] = [];
|
||||
@@ -145,15 +170,71 @@ describe('IssueFacade', () => {
|
||||
it('acts on the open ticket', () => {
|
||||
const { issue, fakes } = setup();
|
||||
|
||||
issue.changeStatus(IssueStatus.IN_PROGRESS);
|
||||
issue.changeStatus('IN_PROGRESS');
|
||||
expect(fakes.issues.changeStatus).not.toHaveBeenCalled();
|
||||
|
||||
issue.open('WEB-1');
|
||||
issue.changeStatus(IssueStatus.QA_FAILED, 'Falla en Safari');
|
||||
issue.changeStatus('QA_FAILED', 'Falla en Safari');
|
||||
expect(fakes.issues.changeStatus).toHaveBeenCalledWith({
|
||||
issueId: 'i1',
|
||||
status: IssueStatus.QA_FAILED,
|
||||
status: 'QA_FAILED',
|
||||
reason: 'Falla en Safari',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('HomeFacade', () => {
|
||||
const issue = (key: string, status: string, statusCategory: string) =>
|
||||
({ id: key, key, status, statusCategory, title: key }) as never;
|
||||
|
||||
it('loads my work and groups it by category, QA failures first', () => {
|
||||
const mine = vi.fn((filter: { assignedToMe: boolean }) =>
|
||||
of(
|
||||
filter.assignedToMe
|
||||
? {
|
||||
items: [
|
||||
issue('WEB-1', 'IN_PROGRESS', 'IN_PROGRESS'),
|
||||
issue('WEB-4', 'BACKLOG', 'TODO'),
|
||||
issue('WEB-2', 'QA_FAILED', 'IN_PROGRESS'),
|
||||
],
|
||||
total: 3,
|
||||
}
|
||||
: { items: [issue('WEB-3', 'STAGING', 'QA')], total: 1 },
|
||||
),
|
||||
);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: IssuesGateway, useValue: { mine } },
|
||||
{
|
||||
provide: ReleasesGateway,
|
||||
useValue: { upcoming: () => of([{ id: 'r1', name: 'v1.4.0' }]) },
|
||||
},
|
||||
],
|
||||
});
|
||||
const home = TestBed.inject(HomeFacade);
|
||||
|
||||
home.load();
|
||||
|
||||
expect(mine).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ assignedToMe: true }),
|
||||
);
|
||||
expect(mine).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
assignedToMe: false,
|
||||
categories: ['QA'],
|
||||
}),
|
||||
);
|
||||
expect(home.loaded()).toBe(true);
|
||||
expect(
|
||||
home
|
||||
.mineByCategory()
|
||||
.map((g) => [g.category, g.issues.map((i) => i.key)]),
|
||||
).toEqual([
|
||||
['IN_PROGRESS', ['WEB-2', 'WEB-1']],
|
||||
['TODO', ['WEB-4']],
|
||||
]);
|
||||
expect(home.qaFailed()).toBe(1);
|
||||
expect(home.awaitingQaTotal()).toBe(1);
|
||||
expect(home.releases().map((r) => r.name)).toEqual(['v1.4.0']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user