generated from coulomb/repo-seed
refactor: use abstract ITEM placeholders with dynamic property mapping
Changed from fixed TASK placeholders to flexible ITEM placeholders that
automatically map all CSV fields to template placeholders.
Key changes:
- Renamed task-template → item-template in all templates
- Changed TASK_* → ITEM_* placeholder naming
- Implemented dynamic placeholder generation from item properties
- Any field in fieldMapping now creates ITEM_{FIELD} placeholder
- Updated all tests and documentation
Naming convention: CSV field → item.property → ITEM_PROPERTY
Example: "assignee" → item.assignee → {{ITEM_ASSIGNEE}}
This enables users to add custom fields without modifying generator code:
- Add "assignee": "Assignee" to fieldMapping
- Use {{ITEM_ASSIGNEE}} in template
- No code changes required
Benefits:
- More flexible and extensible
- Clearer abstraction (items vs tasks)
- Consistent naming convention
- Better documented
All 56 tests passing.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@ A valid template-v2.svg file must contain:
|
||||
2. **`<defs>` section** containing three required template elements:
|
||||
- `<g id="month-template">` - Defines how each month column is rendered
|
||||
- `<g id="lane-template">` - Defines how each lane (epic/swimlane) is rendered
|
||||
- `<g id="task-template">` - Defines how each task item is rendered
|
||||
- `<g id="item-template">` - Defines how each task item is rendered
|
||||
3. **Main content area** with `{{MONTHS}}` and `{{LANES}}` placeholders
|
||||
4. **Optional styling** (gradients, filters, patterns, etc.)
|
||||
|
||||
@@ -37,11 +37,11 @@ A valid template-v2.svg file must contain:
|
||||
font-family="Arial" font-size="14" font-weight="bold" fill="#212121">{{LANE_NAME}}</text>
|
||||
</g>
|
||||
|
||||
<!-- Task template -->
|
||||
<g id="task-template" style="display:none">
|
||||
<circle cx="{{TASK_X}}" cy="{{TASK_Y}}" r="6" fill="#1976D2"/>
|
||||
<!-- Item template -->
|
||||
<g id="item-template" style="display:none">
|
||||
<circle cx="{{ITEM_X}}" cy="{{ITEM_Y}}" r="6" fill="#1976D2"/>
|
||||
<text x="{{TEXT_X}}" y="{{TEXT_Y}}"
|
||||
font-family="Arial" font-size="11" fill="#424242">{{TASK_ID}} {{TASK_TITLE}}</text>
|
||||
font-family="Arial" font-size="11" fill="#424242">{{ITEM_ID}} {{ITEM_TITLE}}</text>
|
||||
</g>
|
||||
</defs>
|
||||
|
||||
@@ -80,16 +80,38 @@ A valid template-v2.svg file must contain:
|
||||
| `{{LABEL_Y}}` | Y position for lane label | 140 |
|
||||
| `{{LANE_NAME}}` | Lane name text (XML-escaped) | "Development" |
|
||||
|
||||
### Task Template Placeholders
|
||||
### Item Template Placeholders
|
||||
|
||||
| Placeholder | Description | Example Value |
|
||||
|------------|-------------|---------------|
|
||||
| `{{TASK_X}}` | X position of task marker | 400 |
|
||||
| `{{TASK_Y}}` | Y position of task marker | 150 |
|
||||
| `{{TEXT_X}}` | X position for task text | 412 |
|
||||
| `{{TEXT_Y}}` | Y position for task text | 154 |
|
||||
| `{{TASK_ID}}` | Task ID (XML-escaped) | "T-123" |
|
||||
| `{{TASK_TITLE}}` | Task title (XML-escaped) | "Implement feature" |
|
||||
| `{{ITEM_X}}` | X position of item marker | 400 |
|
||||
| `{{ITEM_Y}}` | Y position of item marker | 150 |
|
||||
| `{{TEXT_X}}` | X position for item text | 412 |
|
||||
| `{{TEXT_Y}}` | Y position for item text | 154 |
|
||||
| `{{ITEM_ID}}` | Item ID (XML-escaped) | "T-123" |
|
||||
| `{{ITEM_TITLE}}` | Item title (XML-escaped) | "Implement feature" |
|
||||
|
||||
**Dynamic Data Placeholders:**
|
||||
|
||||
The generator automatically creates placeholders for **all properties** in your CSV data using the naming convention: `ITEM_{PROPERTY_UPPERCASE}`.
|
||||
|
||||
For example, if your `fieldMapping` includes:
|
||||
```json
|
||||
{
|
||||
"id": "ID",
|
||||
"title": "Title",
|
||||
"assignee": "Assignee",
|
||||
"priority": "Priority"
|
||||
}
|
||||
```
|
||||
|
||||
The following placeholders become available:
|
||||
- `{{ITEM_ID}}` - from the `id` field
|
||||
- `{{ITEM_TITLE}}` - from the `title` field
|
||||
- `{{ITEM_ASSIGNEE}}` - from the `assignee` field
|
||||
- `{{ITEM_PRIORITY}}` - from the `priority` field
|
||||
|
||||
**Note:** The `due` field is used for positioning and is not available as a placeholder (use `{{MONTH_LABEL}}` for date display).
|
||||
|
||||
### Global Placeholders
|
||||
|
||||
@@ -107,7 +129,7 @@ These appear in the main template body (not in template elements):
|
||||
1. Open template-v2.svg in Inkscape
|
||||
2. Locate template elements in the Layers panel (inside `<defs>`)
|
||||
3. Edit shapes, colors, fonts, etc. as needed
|
||||
4. **Important**: Keep `id="month-template"`, `id="lane-template"`, `id="task-template"` unchanged
|
||||
4. **Important**: Keep `id="month-template"`, `id="lane-template"`, `id="item-template"` unchanged
|
||||
5. **Important**: Keep `{{PLACEHOLDER}}` text exactly as is - these are replaced at runtime
|
||||
6. Save file (keep SVG format, avoid Inkscape-specific extensions)
|
||||
|
||||
@@ -190,8 +212,8 @@ Use SVG filters:
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<g id="task-template" style="display:none">
|
||||
<circle cx="{{TASK_X}}" cy="{{TASK_Y}}" r="6" fill="#1976D2" filter="url(#dropShadow)"/>
|
||||
<g id="item-template" style="display:none">
|
||||
<circle cx="{{ITEM_X}}" cy="{{ITEM_Y}}" r="6" fill="#1976D2" filter="url(#dropShadow)"/>
|
||||
...
|
||||
</g>
|
||||
</defs>
|
||||
@@ -229,7 +251,7 @@ To change the overall layout, you would need to modify generator.js. Templates c
|
||||
- Ensure template has all three required elements in `<defs>`:
|
||||
- `<g id="month-template">`
|
||||
- `<g id="lane-template">`
|
||||
- `<g id="task-template">`
|
||||
- `<g id="item-template">`
|
||||
- Check that IDs are exactly as shown (case-sensitive)
|
||||
- Verify elements are inside `<defs>` section
|
||||
|
||||
|
||||
@@ -36,12 +36,12 @@
|
||||
font-size="14" font-weight="700">{{LANE_NAME}}</text>
|
||||
</g>
|
||||
|
||||
<g id="task-template" style="display: none;">
|
||||
<circle cx="{{TASK_X}}" cy="{{TASK_Y}}" r="6"
|
||||
<g id="item-template" style="display: none;">
|
||||
<circle cx="{{ITEM_X}}" cy="{{ITEM_Y}}" r="6"
|
||||
fill="#2d8659" stroke="#4a9b6b" stroke-width="2"/>
|
||||
<text x="{{TEXT_X}}" y="{{TEXT_Y}}" font-size="12" fill="#2d8659" font-weight="500">
|
||||
<tspan class="item-id">{{TASK_ID}}: </tspan>
|
||||
<tspan class="item-title" fill="#1e5a3d">{{TASK_TITLE}}</tspan>
|
||||
<tspan class="item-id">{{ITEM_ID}}: </tspan>
|
||||
<tspan class="item-title" fill="#1e5a3d">{{ITEM_TITLE}}</tspan>
|
||||
</text>
|
||||
</g>
|
||||
</defs>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
39
generator.js
39
generator.js
@@ -54,13 +54,13 @@ window.timelineGenerator = {
|
||||
validateTemplate(template) {
|
||||
const hasMonthTemplate = template.includes('id="month-template"');
|
||||
const hasLaneTemplate = template.includes('id="lane-template"');
|
||||
const hasTaskTemplate = template.includes('id="task-template"');
|
||||
const hasItemTemplate = template.includes('id="item-template"');
|
||||
|
||||
if (!hasMonthTemplate || !hasLaneTemplate || !hasTaskTemplate) {
|
||||
if (!hasMonthTemplate || !hasLaneTemplate || !hasItemTemplate) {
|
||||
const missing = [];
|
||||
if (!hasMonthTemplate) missing.push('month-template');
|
||||
if (!hasLaneTemplate) missing.push('lane-template');
|
||||
if (!hasTaskTemplate) missing.push('task-template');
|
||||
if (!hasItemTemplate) missing.push('item-template');
|
||||
|
||||
throw new Error(
|
||||
`Template is missing required elements: ${missing.join(', ')}. ` +
|
||||
@@ -97,7 +97,7 @@ window.timelineGenerator = {
|
||||
// Extract template elements (will throw if not found)
|
||||
const monthTemplate = this.extractTemplate(template, 'month-template');
|
||||
const laneTemplate = this.extractTemplate(template, 'lane-template');
|
||||
const taskTemplate = this.extractTemplate(template, 'task-template');
|
||||
const itemTemplate = this.extractTemplate(template, 'item-template');
|
||||
|
||||
const monthLabelY = 90;
|
||||
const gridTop = top - 20;
|
||||
@@ -156,7 +156,7 @@ window.timelineGenerator = {
|
||||
laneElement = laneElement.replace(/id="lane-template"/, '');
|
||||
laneBlocks += laneElement;
|
||||
|
||||
// Generate tasks for this lane
|
||||
// Generate items for this lane
|
||||
const laneItems = laneMap.get(laneName).sort((a, b) => a.due - b.due);
|
||||
laneItems.forEach((it, idx) => {
|
||||
const mi = monthIndexForDate(it.due);
|
||||
@@ -164,19 +164,26 @@ window.timelineGenerator = {
|
||||
const cx = left + clampedMi * monthWidth + monthWidth * 0.5;
|
||||
const cy = laneY + 10 + idx * 18;
|
||||
|
||||
const taskValues = {
|
||||
TASK_X: cx,
|
||||
TASK_Y: cy,
|
||||
// Start with layout placeholders
|
||||
const itemValues = {
|
||||
ITEM_X: cx,
|
||||
ITEM_Y: cy,
|
||||
TEXT_X: cx + 12,
|
||||
TEXT_Y: cy + 4,
|
||||
TASK_ID: this.escapeXml(it.id || ""),
|
||||
TASK_TITLE: this.escapeXml(it.title || "")
|
||||
TEXT_Y: cy + 4
|
||||
};
|
||||
|
||||
let taskElement = this.replacePlaceholders(taskTemplate, taskValues);
|
||||
taskElement = taskElement.replace(/style="display:\s*none;?"/, '');
|
||||
taskElement = taskElement.replace(/id="task-template"/, '');
|
||||
laneBlocks += taskElement;
|
||||
// Dynamically add data placeholders from all item properties
|
||||
for (const [key, value] of Object.entries(it)) {
|
||||
if (key !== 'due') { // Skip due date as it's used for positioning
|
||||
const placeholderName = `ITEM_${key.toUpperCase()}`;
|
||||
itemValues[placeholderName] = this.escapeXml(value || "");
|
||||
}
|
||||
}
|
||||
|
||||
let itemElement = this.replacePlaceholders(itemTemplate, itemValues);
|
||||
itemElement = itemElement.replace(/style="display:\s*none;?"/, '');
|
||||
itemElement = itemElement.replace(/id="item-template"/, '');
|
||||
laneBlocks += itemElement;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,7 +200,7 @@ window.timelineGenerator = {
|
||||
processedTemplate = processedTemplate
|
||||
.replace(/<g id="month-template"[^>]*>[\s\S]*?<\/g>/, '')
|
||||
.replace(/<g id="lane-template"[^>]*>[\s\S]*?<\/g>/, '')
|
||||
.replace(/<g id="task-template"[^>]*>[\s\S]*?<\/g>/, '');
|
||||
.replace(/<g id="item-template"[^>]*>[\s\S]*?<\/g>/, '');
|
||||
|
||||
// Add width and height attributes to the SVG element
|
||||
processedTemplate = processedTemplate.replace(
|
||||
|
||||
@@ -36,12 +36,12 @@
|
||||
font-size="14" font-weight="700">{{LANE_NAME}}</text>
|
||||
</g>
|
||||
|
||||
<g id="task-template" style="display: none;">
|
||||
<circle cx="{{TASK_X}}" cy="{{TASK_Y}}" r="6"
|
||||
<g id="item-template" style="display: none;">
|
||||
<circle cx="{{ITEM_X}}" cy="{{ITEM_Y}}" r="6"
|
||||
fill="#3b82f6" stroke="#60a5fa" stroke-width="2"/>
|
||||
<text x="{{TEXT_X}}" y="{{TEXT_Y}}" font-size="12" fill="#3b82f6" font-weight="500">
|
||||
<tspan class="item-id">{{TASK_ID}}: </tspan>
|
||||
<tspan class="item-title" fill="#1e40af">{{TASK_TITLE}}</tspan>
|
||||
<tspan class="item-id">{{ITEM_ID}}: </tspan>
|
||||
<tspan class="item-title" fill="#1e40af">{{ITEM_TITLE}}</tspan>
|
||||
</text>
|
||||
</g>
|
||||
</defs>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
@@ -66,12 +66,12 @@ describe('Timeline Generator', () => {
|
||||
}).toThrow('Template is missing required elements: lane-template')
|
||||
})
|
||||
|
||||
it('should throw error when template is missing task-template', () => {
|
||||
const malformedTemplate = createMalformedTemplate('task-template')
|
||||
it('should throw error when template is missing item-template', () => {
|
||||
const malformedTemplate = createMalformedTemplate('item-template')
|
||||
|
||||
expect(() => {
|
||||
timelineGenerator.generate(items, config, malformedTemplate)
|
||||
}).toThrow('Template is missing required elements: task-template')
|
||||
}).toThrow('Template is missing required elements: item-template')
|
||||
})
|
||||
|
||||
it('should validate template with proper error message', () => {
|
||||
@@ -179,8 +179,8 @@ describe('Timeline Generator', () => {
|
||||
|
||||
expect(result).not.toContain('{{MONTH_X}}')
|
||||
expect(result).not.toContain('{{LANE_Y}}')
|
||||
expect(result).not.toContain('{{TASK_X}}')
|
||||
expect(result).not.toContain('{{TASK_TITLE}}')
|
||||
expect(result).not.toContain('{{ITEM_X}}')
|
||||
expect(result).not.toContain('{{ITEM_TITLE}}')
|
||||
})
|
||||
|
||||
it('should contain task data in generated SVG', () => {
|
||||
@@ -248,7 +248,7 @@ describe('Timeline Generator', () => {
|
||||
|
||||
expect(result).not.toContain('id="month-template"')
|
||||
expect(result).not.toContain('id="lane-template"')
|
||||
expect(result).not.toContain('id="task-template"')
|
||||
expect(result).not.toContain('id="item-template"')
|
||||
expect(result).not.toContain('style="display:none"')
|
||||
})
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('Timeline Integration', () => {
|
||||
// Verify no template placeholders remain
|
||||
expect(viewer.innerHTML).not.toContain('{{MONTH_X}}')
|
||||
expect(viewer.innerHTML).not.toContain('{{LANE_Y}}')
|
||||
expect(viewer.innerHTML).not.toContain('{{TASK_X}}')
|
||||
expect(viewer.innerHTML).not.toContain('{{ITEM_X}}')
|
||||
|
||||
// Verify download button enabled
|
||||
const downloadBtn = document.getElementById('downloadSvg')
|
||||
@@ -257,9 +257,9 @@ describe('Timeline Integration', () => {
|
||||
expect(viewer).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should reject malformed template-v2 (missing task-template)', async () => {
|
||||
it('should reject malformed template-v2 (missing item-template)', async () => {
|
||||
const config = createSampleProject()
|
||||
const malformedTemplate = createMalformedTemplate('task-template')
|
||||
const malformedTemplate = createMalformedTemplate('item-template')
|
||||
|
||||
mockFetch(malformedTemplate)
|
||||
mockFetch(createSampleCSV())
|
||||
|
||||
@@ -61,9 +61,9 @@ export const createSampleTemplate = () => `<svg xmlns="http://www.w3.org/2000/sv
|
||||
</g>
|
||||
|
||||
<!-- Task template -->
|
||||
<g id="task-template" style="display:none">
|
||||
<circle cx="{{TASK_X}}" cy="{{TASK_Y}}" r="6" fill="#1976D2"/>
|
||||
<text x="{{TEXT_X}}" y="{{TEXT_Y}}" font-family="Arial" font-size="11" fill="#424242">{{TASK_ID}} {{TASK_TITLE}}</text>
|
||||
<g id="item-template" style="display:none">
|
||||
<circle cx="{{ITEM_X}}" cy="{{ITEM_Y}}" r="6" fill="#1976D2"/>
|
||||
<text x="{{TEXT_X}}" y="{{TEXT_Y}}" font-family="Arial" font-size="11" fill="#424242">{{ITEM_ID}} {{ITEM_TITLE}}</text>
|
||||
</g>
|
||||
</defs>
|
||||
|
||||
@@ -80,8 +80,8 @@ export const createMalformedTemplate = (missingElement = 'month-template') => {
|
||||
<g id="lane-template" style="display:none">
|
||||
<rect x="{{LANE_X}}" y="{{LANE_Y}}" width="{{LANE_WIDTH}}" height="{{LANE_HEIGHT}}" fill="#FAFAFA"/>
|
||||
</g>
|
||||
<g id="task-template" style="display:none">
|
||||
<circle cx="{{TASK_X}}" cy="{{TASK_Y}}" r="6" fill="#1976D2"/>
|
||||
<g id="item-template" style="display:none">
|
||||
<circle cx="{{ITEM_X}}" cy="{{ITEM_Y}}" r="6" fill="#1976D2"/>
|
||||
</g>
|
||||
</defs>
|
||||
{{MONTHS}}
|
||||
@@ -92,14 +92,14 @@ export const createMalformedTemplate = (missingElement = 'month-template') => {
|
||||
<g id="month-template" style="display:none">
|
||||
<line x1="{{MONTH_X}}" y1="{{GRID_TOP}}" x2="{{MONTH_X}}" y2="{{GRID_BOTTOM}}" stroke="#E0E0E0"/>
|
||||
</g>
|
||||
<g id="task-template" style="display:none">
|
||||
<circle cx="{{TASK_X}}" cy="{{TASK_Y}}" r="6" fill="#1976D2"/>
|
||||
<g id="item-template" style="display:none">
|
||||
<circle cx="{{ITEM_X}}" cy="{{ITEM_Y}}" r="6" fill="#1976D2"/>
|
||||
</g>
|
||||
</defs>
|
||||
{{MONTHS}}
|
||||
{{LANES}}
|
||||
</svg>`,
|
||||
'task-template': `<svg xmlns="http://www.w3.org/2000/svg">
|
||||
'item-template': `<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<g id="month-template" style="display:none">
|
||||
<line x1="{{MONTH_X}}" y1="{{GRID_TOP}}" x2="{{MONTH_X}}" y2="{{GRID_BOTTOM}}" stroke="#E0E0E0"/>
|
||||
|
||||
Reference in New Issue
Block a user