Show proposals and votes from TaskStream on a project's page
The most voted bug reports and ideas, the link to send one while the board takes them, and how many want each epic on the roadmap. The mock API serves a showcase for the catalogue so every section can be seen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1621,7 +1621,14 @@ paths:
|
||||
patch:
|
||||
tags: [Projects]
|
||||
summary: Update a project
|
||||
description: Partial update. Translatable fields merge into the existing map.
|
||||
description: |
|
||||
Partial update. Translatable fields merge into the existing map.
|
||||
|
||||
`taskstream: true` opens a TaskStream board for a project that has none
|
||||
yet, as on creation (`taskstream_key`, or one made from the slug); if
|
||||
TaskStream refuses, the update is not saved either. A project that
|
||||
already has a board keeps it, and `taskstream_key` without the flag is
|
||||
ignored, so a client can PATCH back what it read.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -5574,12 +5581,12 @@ components:
|
||||
demo_url: { type: string, format: uri, nullable: true }
|
||||
taskstream:
|
||||
type: boolean
|
||||
description: On creation only. Also open a public board for it in TaskStream; see `POST /v1/projects`.
|
||||
description: Also open a public board for it in TaskStream, on creation or when it has none yet; see `POST /v1/projects`.
|
||||
taskstream_key:
|
||||
type: string
|
||||
nullable: true
|
||||
pattern: "^[A-Za-z][A-Za-z0-9]{1,9}$"
|
||||
description: On creation only, with `taskstream`. The key in TaskStream; made from the slug when left out.
|
||||
description: Only read with `taskstream`. The key in TaskStream; made from the slug when left out.
|
||||
cover_image: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=projects`. }
|
||||
started_at: { type: string, format: date, nullable: true }
|
||||
ended_at: { type: string, format: date, nullable: true }
|
||||
|
||||
@@ -117,6 +117,11 @@ export interface Copy {
|
||||
goodFirstLead: string;
|
||||
board: string;
|
||||
done: string;
|
||||
proposals: string;
|
||||
proposalsLead: string;
|
||||
propose: string;
|
||||
voteOne: string;
|
||||
voteMany: string;
|
||||
};
|
||||
member: {
|
||||
projects: string;
|
||||
@@ -317,6 +322,11 @@ const es: Copy = {
|
||||
goodFirstLead: 'Tareas pensadas para quien llega nuevo.',
|
||||
board: 'Ver el tablero',
|
||||
done: 'Hecho',
|
||||
proposals: 'Ideas y errores',
|
||||
proposalsLead: 'Lo que más se pide. Para votar, entra en el tablero.',
|
||||
propose: 'Proponer una idea o avisar de un error',
|
||||
voteOne: '1 voto',
|
||||
voteMany: '{n} votos',
|
||||
},
|
||||
member: {
|
||||
projects: 'Proyectos',
|
||||
@@ -536,6 +546,11 @@ const en: Copy = {
|
||||
goodFirstLead: 'Tasks meant for newcomers.',
|
||||
board: 'See the board',
|
||||
done: 'Done',
|
||||
proposals: 'Ideas and bugs',
|
||||
proposalsLead: 'What people ask for most. To vote, sign in on the board.',
|
||||
propose: 'Suggest an idea or report a bug',
|
||||
voteOne: '1 vote',
|
||||
voteMany: '{n} votes',
|
||||
},
|
||||
member: {
|
||||
projects: 'Projects',
|
||||
@@ -755,6 +770,11 @@ const ca: Copy = {
|
||||
goodFirstLead: 'Tasques pensades per a qui arriba de nou.',
|
||||
board: 'Veure el tauler',
|
||||
done: 'Fet',
|
||||
proposals: 'Idees i errors',
|
||||
proposalsLead: 'El que més es demana. Per votar, entra al tauler.',
|
||||
propose: "Proposar una idea o avisar d'un error",
|
||||
voteOne: '1 vot',
|
||||
voteMany: '{n} vots',
|
||||
},
|
||||
member: {
|
||||
projects: 'Projectes',
|
||||
|
||||
@@ -44,4 +44,17 @@ describe('TaskStream showcase', () => {
|
||||
expect(hasContent(showcase)).toBe(true);
|
||||
expect(hasContent({ ...showcase, goodFirst: [] })).toBe(false);
|
||||
});
|
||||
|
||||
it('counts a board that takes proposals as something to show', () => {
|
||||
const empty = { ...showcase, goodFirst: [] };
|
||||
expect(
|
||||
hasContent({
|
||||
...empty,
|
||||
proposals: { open: true, path: '/p/WEB/proponer', top: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasContent({ ...empty, proposals: { open: false, path: null, top: [] } }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* A project's board in TaskStream, the collective's issue tracker: its public
|
||||
* showcase (roadmap, releases, the sprint under way and tickets for
|
||||
* newcomers). Fetched on the server; a slow or absent TaskStream only means
|
||||
* showcase (roadmap, releases, the sprint under way, tickets for newcomers
|
||||
* and the proposals people send and vote for). Fetched on the server; a slow or absent TaskStream only means
|
||||
* the project page goes without those sections.
|
||||
*/
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface TaskStreamShowcase {
|
||||
dueDate: string | null;
|
||||
percent: number;
|
||||
done: boolean;
|
||||
/** How many want it; older TaskStreams leave it out. */
|
||||
votes?: number;
|
||||
path: string;
|
||||
}[];
|
||||
releases: {
|
||||
@@ -37,6 +39,21 @@ export interface TaskStreamShowcase {
|
||||
notes: string | null;
|
||||
}[];
|
||||
goodFirst: { key: string; title: string; type: string; path: string }[];
|
||||
/** Bug reports and ideas from anyone; older TaskStreams leave it out. */
|
||||
proposals?: {
|
||||
/** Whether it takes them now. */
|
||||
open: boolean;
|
||||
/** The form in TaskStream's web app, while it takes them. */
|
||||
path: string | null;
|
||||
/** Open ones, most voted first. */
|
||||
top: {
|
||||
key: string;
|
||||
title: string;
|
||||
type: string;
|
||||
path: string;
|
||||
votes: number;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
/** What the page renders: the showcase plus where its links go. */
|
||||
@@ -75,7 +92,9 @@ export function hasContent(showcase: TaskStreamShowcase): boolean {
|
||||
showcase.roadmap.length > 0 ||
|
||||
showcase.releases.length > 0 ||
|
||||
showcase.goodFirst.length > 0 ||
|
||||
showcase.activeSprint !== null
|
||||
showcase.activeSprint !== null ||
|
||||
!!showcase.proposals?.open ||
|
||||
(showcase.proposals?.top.length ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,8 @@
|
||||
|
||||
.project-roadmap,
|
||||
.project-releases,
|
||||
.project-first {
|
||||
.project-first,
|
||||
.project-proposals {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
@@ -134,3 +135,21 @@
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.project-proposals li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 1rem;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
// Clear of the lead above and of the link below.
|
||||
.project-roadmap,
|
||||
.project-proposals {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.project-proposals__propose {
|
||||
margin: 1.25rem 0 0;
|
||||
}
|
||||
|
||||
@@ -155,9 +155,12 @@ import { LinkButton } from './link-button';
|
||||
<a [href]="link(epic.path)" target="_blank" rel="noopener">{{
|
||||
epic.title
|
||||
}}</a>
|
||||
<span class="content-page__muted project-roadmap__when">{{
|
||||
dates(epic.startDate, epic.dueDate)
|
||||
}}</span>
|
||||
<span class="content-page__muted project-roadmap__when"
|
||||
>{{ dates(epic.startDate, epic.dueDate) }}
|
||||
@if (epic.votes && !epic.done) {
|
||||
· {{ votes(epic.votes) }}
|
||||
}
|
||||
</span>
|
||||
<span
|
||||
class="project-roadmap__bar"
|
||||
role="img"
|
||||
@@ -169,6 +172,33 @@ import { LinkButton } from './link-button';
|
||||
</ol>
|
||||
</section>
|
||||
}
|
||||
@if (b.showcase.proposals; as proposals) {
|
||||
@if (proposals.open || proposals.top.length > 0) {
|
||||
<section class="content-page__section">
|
||||
<h2>{{ copy().project.proposals }}</h2>
|
||||
@if (proposals.top.length > 0) {
|
||||
<p class="content-page__muted">{{ copy().project.proposalsLead }}</p>
|
||||
<ol class="project-proposals">
|
||||
@for (proposal of proposals.top; track proposal.key) {
|
||||
<li>
|
||||
<a [href]="link(proposal.path)" target="_blank" rel="noopener">{{
|
||||
proposal.title
|
||||
}}</a>
|
||||
<span class="content-page__muted">{{ votes(proposal.votes) }}</span>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
@if (proposals.open && proposals.path) {
|
||||
<p class="project-proposals__propose">
|
||||
<a [href]="link(proposals.path)" target="_blank" rel="noopener"
|
||||
>{{ copy().project.propose }} →</a
|
||||
>
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@if (b.showcase.releases.length > 0) {
|
||||
<section class="content-page__section">
|
||||
<h2>{{ copy().project.releases }}</h2>
|
||||
@@ -397,6 +427,12 @@ export class ProjectDetail {
|
||||
return boardLink(this.board()?.webUrl ?? '', path);
|
||||
}
|
||||
|
||||
/** "1 voto", "3 votos". */
|
||||
protected votes(count: number): string {
|
||||
const copy = this.copy().project;
|
||||
return count === 1 ? copy.voteOne : copy.voteMany.replace('{n}', String(count));
|
||||
}
|
||||
|
||||
/** "3 nov – 20 dic 2026", or one end when only one is known. */
|
||||
protected dates(start: string | null, end: string | null): string {
|
||||
const format = (date: string | null) =>
|
||||
|
||||
Reference in New Issue
Block a user