Skip to content

Commit e9a8e04

Browse files
committed
Show Milestones in the timeline widget
1 parent 0b2b5c5 commit e9a8e04

8 files changed

Lines changed: 398 additions & 116 deletions

File tree

frontend/src/app/shared/components/project-timeline-graph/project-timeline-graph.component.sass

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,16 @@
9292
font-weight: var(--base-text-weight-semibold)
9393
cursor: pointer
9494
padding: 0 var(--base-size-6)
95+
96+
// Milestones: diamond shape via CSS rotate on the vis-dot
97+
.vis-item.vis-point.op-timeline-milestone
98+
background: none !important
99+
border-color: transparent !important
100+
cursor: pointer
101+
102+
.vis-item.vis-dot.op-timeline-milestone
103+
width: 10px
104+
height: 10px
105+
border-width: 0
106+
border-radius: 0
107+
rotate: 45deg

frontend/src/app/shared/components/project-timeline-graph/project-timeline-graph.component.spec.ts

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import { ComponentFixture, TestBed } from '@angular/core/testing';
3030
import { I18nService } from 'core-app/core/i18n/i18n.service';
3131
import { TimezoneService } from 'core-app/core/datetime/timezone.service';
32+
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
3233
import { ProjectTimelineItem, ProjectTimelineGraphComponent } from './project-timeline-graph.component';
3334

3435
describe('ProjectTimelineGraphComponent', () => {
@@ -37,6 +38,7 @@ describe('ProjectTimelineGraphComponent', () => {
3738
return {
3839
'js.grid.widgets.project_timeline.tooltip_type_phase': 'Phase',
3940
'js.grid.widgets.project_timeline.tooltip_type_gate': 'Gate',
41+
'js.grid.widgets.project_timeline.tooltip_type_milestone': 'Milestone',
4042
}[key] ?? key;
4143
},
4244
};
@@ -93,10 +95,18 @@ describe('ProjectTimelineGraphComponent', () => {
9395
finishGateName: null,
9496
};
9597

98+
const milestone = {
99+
id: 10,
100+
subject: 'Launch',
101+
date: '2024-06-30',
102+
typeId: 7,
103+
};
104+
96105
let fixture:ComponentFixture<ProjectTimelineGraphComponent>;
97106
let component:ProjectTimelineGraphComponent;
98107

99-
let buildData:(phases:unknown[]) => { items:ProjectTimelineItem[]; groups:{ id:string; content:string }[] };
108+
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-assignment
109+
let buildData:(phases:unknown[], milestones:unknown[]) => { items:ProjectTimelineItem[]; groups:{ id:string; content:string }[] };
100110
let tooltipTemplate:(item:ProjectTimelineItem) => HTMLElement|string;
101111

102112
beforeEach(async () => {
@@ -105,14 +115,16 @@ describe('ProjectTimelineGraphComponent', () => {
105115
providers: [
106116
{ provide: I18nService, useValue: i18nStub },
107117
{ provide: TimezoneService, useValue: timezoneStub },
118+
{ provide: PathHelperService, useValue: { workPackagePath: (id:string) => `/work_packages/${id}` } },
108119
],
109120
}).compileComponents();
110121

111122
fixture = TestBed.createComponent(ProjectTimelineGraphComponent);
112123
component = fixture.componentInstance;
113124

114-
// Set required input before detectChanges triggers ngAfterViewInit
125+
// Set required inputs before detectChanges triggers ngAfterViewInit
115126
fixture.componentRef.setInput('phasesData', '[]');
127+
fixture.componentRef.setInput('milestonesData', '[]');
116128
fixture.detectChanges();
117129

118130
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-assignment
@@ -123,31 +135,33 @@ describe('ProjectTimelineGraphComponent', () => {
123135

124136
describe('buildData', () => {
125137
it('creates a range item for a phase with dates', () => {
126-
const { items } = buildData([phaseWithDates]);
138+
const { items } = buildData([phaseWithDates], []);
127139
const item = items.find((i) => i.id === 'phase-1');
128140

129141
expect(item).toBeDefined();
130142
expect(item!.type).toBe('range');
131-
expect(item!.group).toBe('lifecycle');
143+
expect(item!.group).toBe('phases');
132144
expect(item!.content).toBe('Design');
133145
expect(item!.className).toContain('__hl_background_project_phase_definition_3');
146+
expect(item!.itemType).toBe('phase');
134147
expect(item!.definitionId).toBe(3);
135148
});
136149

137150
it('skips a phase without dates', () => {
138-
const { items } = buildData([phaseWithoutDates]);
151+
const { items } = buildData([phaseWithoutDates], []);
139152
expect(items.find((i) => i.id === 'phase-3')).toBeUndefined();
140153
});
141154

142155
it('creates start and finish gate items when configured', () => {
143-
const { items } = buildData([phaseWithGates]);
156+
const { items } = buildData([phaseWithGates], []);
144157

145158
const startGate = items.find((i) => i.id === 'gate-start-2');
146159
expect(startGate).toBeDefined();
147160
expect(startGate!.type).toBe('point');
148161
expect(startGate!.group).toBe('gates');
149162
expect(startGate!.title).toBe('Build Start');
150163
expect(startGate!.className).toContain('op-timeline-gate');
164+
expect(startGate!.itemType).toBe('gate');
151165
expect(startGate!.content instanceof HTMLElement).toBe(true);
152166
expect((startGate!.content as HTMLElement).querySelector('.__hl_inline_project_phase_definition_5')).toBeTruthy();
153167

@@ -157,7 +171,7 @@ describe('ProjectTimelineGraphComponent', () => {
157171
});
158172

159173
it('does not create gate items when gates are disabled', () => {
160-
const { items } = buildData([phaseWithDates]);
174+
const { items } = buildData([phaseWithDates], []);
161175
expect(items.find((i) => i.id === 'gate-start-1')).toBeUndefined();
162176
expect(items.find((i) => i.id === 'gate-finish-1')).toBeUndefined();
163177
});
@@ -166,7 +180,7 @@ describe('ProjectTimelineGraphComponent', () => {
166180
let item:ProjectTimelineItem;
167181

168182
beforeEach(() => {
169-
({ items: [item] } = buildData([oneDayPhase]));
183+
({ items: [item] } = buildData([oneDayPhase], []));
170184
});
171185

172186
it('creates a range item', () => {
@@ -193,9 +207,22 @@ describe('ProjectTimelineGraphComponent', () => {
193207
});
194208
});
195209

196-
it('always creates gates and lifecycle groups', () => {
197-
const { groups } = buildData([]);
198-
expect(groups.map((g) => g.id)).toEqual(['gates', 'lifecycle']);
210+
it('creates a milestone item', () => {
211+
const { items } = buildData([], [milestone]);
212+
const item = items.find((i) => i.id === 'milestone-10');
213+
214+
expect(item).toBeDefined();
215+
expect(item!.type).toBe('point');
216+
expect(item!.group).toBe('milestones');
217+
expect(item!.title).toBe('Launch');
218+
expect(item!.className).toContain('op-timeline-milestone');
219+
expect(item!.className).toContain('__hl_background_type_7');
220+
expect(item!.itemType).toBe('milestone');
221+
});
222+
223+
it('always creates gates, lifecycle, and milestones groups', () => {
224+
const { groups } = buildData([], []);
225+
expect(groups.map((g) => g.id)).toEqual(['gates', 'phases', 'milestones']);
199226
});
200227
});
201228

@@ -218,6 +245,7 @@ describe('ProjectTimelineGraphComponent', () => {
218245
content: 'Design',
219246
title: 'Design',
220247
className: '__hl_background_project_phase_definition_3',
248+
itemType: 'phase',
221249
definitionId: 3,
222250
}) as HTMLElement;
223251
});
@@ -282,6 +310,7 @@ describe('ProjectTimelineGraphComponent', () => {
282310
content: document.createElement('i'),
283311
title: 'Build Start',
284312
className: 'op-timeline-gate __hl_background_project_phase_definition_5',
313+
itemType: 'gate',
285314
definitionId: 5,
286315
}) as HTMLElement;
287316
});
@@ -305,5 +334,42 @@ describe('ProjectTimelineGraphComponent', () => {
305334
expect(result.querySelector('.__hl_inline_project_phase_definition_5')).toBeTruthy();
306335
});
307336
});
337+
338+
describe('for a milestone item', () => {
339+
let result:HTMLElement;
340+
341+
beforeEach(() => {
342+
result = tooltipTemplate({
343+
id: 'milestone-10',
344+
group: 'milestones',
345+
type: 'point',
346+
start: new Date('2024-06-30'),
347+
content: '',
348+
title: 'Launch',
349+
className: 'op-timeline-milestone __hl_background_type_7',
350+
itemType: 'milestone',
351+
typeId: 7,
352+
}) as HTMLElement;
353+
});
354+
355+
it('shows "Milestone" as the type label', () => {
356+
const meta = result.querySelector('.op-timeline-tooltip--meta-row');
357+
expect(meta?.textContent).toContain('Milestone');
358+
});
359+
360+
it('shows only a single date (no range)', () => {
361+
const meta = result.querySelector('.op-timeline-tooltip--meta-row');
362+
expect(meta?.textContent).not.toContain('–');
363+
});
364+
365+
it('shows the milestone name', () => {
366+
const name = result.querySelector('.op-timeline-tooltip--name');
367+
expect(name?.textContent).toBe('Launch');
368+
});
369+
370+
it('applies the type highlight class to the icon', () => {
371+
expect(result.querySelector('.__hl_inline_type_7')).toBeTruthy();
372+
});
373+
});
308374
});
309375
});

0 commit comments

Comments
 (0)