docs/FRONTERA_UPSTREAM.md mide como cambia upstream (39 releases de la 1.0.1 a la 1.3.3; backend.js en 34, main_views en 31, OasisMobile.css en 21) y fija que se toca: backend, middleware, modelos, server y traducciones de upstream, no; vistas de upstream, solo una linea de gancho; hojas, vistas, router y modelos propios, si. Fase A, lo del 19/10: - backend.js, workflows_model.js y modules_view.js vuelven a como estaban - seccion apagada, /modules/enable y ?saved=1: en el router propio, que envuelve las redirecciones de upstream en vez de cambiar checkMod - Modulos del movil en views/mobile_modules_view.js, servida por el router propio - workflow propio "acampada" registrado desde models/fork_workflows.js en vez de redefinir "activists" - votaciones, tribus, ajustes, borrar, pie y bienvenida: la logica en views/mobile_ui.js; en cada vista de upstream queda la llamada
368 lines
18 KiB
JavaScript
368 lines
18 KiB
JavaScript
const { div, h2, p, section, button, form, a, textarea, br, input, table, tr, th, td, label, span } = require("../server/node_modules/hyperaxe");
|
|
const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view");
|
|
const { template, i18n, renderOpinionsVoting, renderEngagement, renderOpenClosedChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions, renderSpreadEditWarning, renderDocumentActions, renderModuleStatsBy, moduleIsEmpty, contentDeleteAction, paged, renderCreatedRows } = require("./main_views");
|
|
const moment = require("../server/node_modules/moment");
|
|
const { config } = require("../server/SSB_server.js");
|
|
const { renderTribeOriginChip } = require("./clearnet_view");
|
|
const mobileUi = require("./mobile_ui");
|
|
|
|
const userId = config.keys.id;
|
|
const VOTE_QUORUM = 2;
|
|
|
|
const safeArray = (v) => Array.isArray(v) ? v : [];
|
|
|
|
const voteLabel = (opt) =>
|
|
i18n["vote" + opt.split("_").map(w => w.charAt(0) + w.slice(1).toLowerCase()).join("")] || opt;
|
|
|
|
const computeVoteOutcome = (baseCounts, voteOptions, totalVotesNum) => {
|
|
const noResult = { text: i18n.voteNoQuorum || "NO QUORUM", variant: "pending" };
|
|
if (totalVotesNum < VOTE_QUORUM) return noResult;
|
|
const opts = voteOptions.filter((o) => o !== "FOLLOW_MAJORITY");
|
|
const maxCount = opts.reduce((m, o) => Math.max(m, baseCounts[o] || 0), 0);
|
|
const topOpts = opts.filter((o) => (baseCounts[o] || 0) === maxCount);
|
|
if (maxCount === 0 || topOpts.length > 1) return noResult;
|
|
const winner = topOpts[0];
|
|
const variant = (winner === "YES" || winner === "GREEN") ? "yes" : (winner === "NO" || winner === "RED") ? "no" : "pending";
|
|
return { text: voteLabel(winner), variant };
|
|
};
|
|
|
|
const normalizeStatus = (v) => {
|
|
const up = String(v || "").toUpperCase();
|
|
if (up === "OPEN" || up === "CLOSED") return up;
|
|
return up || "OPEN";
|
|
};
|
|
|
|
const statusLabel = (s) => {
|
|
const up = normalizeStatus(s);
|
|
if (up === "OPEN") return i18n.voteStatusOpen || i18n.voteFilterOpen || "OPEN";
|
|
if (up === "CLOSED") return i18n.voteStatusClosed || i18n.voteFilterClosed || "CLOSED";
|
|
return up;
|
|
};
|
|
|
|
const { isVoteLocked } = require("../models/votes_model");
|
|
const isVoteAuthor = (v) => !!(v && v.createdBy && String(v.createdBy) === String(userId));
|
|
const canModifyVote = (v) => isVoteAuthor(v) && !isVoteLocked(v);
|
|
|
|
const renderVoteOwnerActions = (v, returnTo) => {
|
|
const showUpdateButton = canModifyVote(v);
|
|
|
|
const actions = [];
|
|
if (showUpdateButton) {
|
|
actions.push(
|
|
form(
|
|
{ method: "GET", action: `/votes/edit/${encodeURIComponent(v.id)}` },
|
|
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
|
button({ class: "update-btn", type: "submit" }, i18n.voteUpdateButton)
|
|
)
|
|
);
|
|
}
|
|
return actions;
|
|
};
|
|
|
|
const renderVoteButtons = (v, voteOptions, firstRow, secondRow, returnTo) => {
|
|
if (normalizeStatus(v.status) !== "OPEN") return null;
|
|
if (v.createdBy && String(v.createdBy) === String(userId)) return null;
|
|
|
|
const allOptions = Array.isArray(v.options) && v.options.length ? v.options : [...firstRow, ...secondRow];
|
|
return div(
|
|
{ class: "vote-buttons-block" },
|
|
div(
|
|
{ class: "vote-buttons-row-single" },
|
|
...allOptions.map((opt) =>
|
|
form(
|
|
{ method: "POST", action: `/votes/vote/${encodeURIComponent(v.id)}` },
|
|
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
|
button({ type: "submit", name: "choice", value: opt }, voteLabel(opt))
|
|
)
|
|
)
|
|
)
|
|
);
|
|
};
|
|
|
|
const renderOpinionsBar = (v, returnTo) =>
|
|
renderOpinionsVoting('/votes/opinions', v.id, v.opinions, returnTo, v.opinions_inhabitants);
|
|
|
|
const renderVoteStatusChip = (status) => {
|
|
const localized = statusLabel(status);
|
|
return renderOpenClosedChip(status, { statusChipOPEN: localized, statusChipCLOSED: localized });
|
|
};
|
|
|
|
const renderVoteListItem = exports.renderVoteListItem = (v, voteOptionsDefault, activeFilter, spreadInfo, opts = {}) => {
|
|
const voteOptions = Array.isArray(v.options) && v.options.length ? v.options : voteOptionsDefault;
|
|
const baseCounts = voteOptions.reduce((acc, opt) => {
|
|
acc[opt] = (v.votes && v.votes[opt]) ? v.votes[opt] : 0;
|
|
return acc;
|
|
}, {});
|
|
const totalVotesNum = typeof v.totalVotes === "number" ? v.totalVotes : parseInt(String(v.totalVotes || "0"), 10) || 0;
|
|
const outcome = computeVoteOutcome(baseCounts, voteOptions, totalVotesNum);
|
|
const origin = v.tribeOrigin || null;
|
|
const href = opts.href !== undefined ? opts.href : (origin ? origin.href : `/votes/${encodeURIComponent(v.id)}`);
|
|
const chips = [
|
|
renderVoteStatusChip(v.status),
|
|
renderLifespanChip(v.lifetime, i18n),
|
|
origin ? renderTribeOriginChip(origin) : null,
|
|
...(Array.isArray(opts.extraChips) ? opts.extraChips : [])
|
|
].filter(Boolean);
|
|
const returnTo = `/votes?filter=${encodeURIComponent(activeFilter || "all")}`;
|
|
const totalOpinions = Object.values(v.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
|
|
const isOwn = isVoteAuthor(v);
|
|
const headerActions = opts.headerActions !== undefined
|
|
? opts.headerActions
|
|
: origin ? null : renderContentActions(v.id, `/votes/${encodeURIComponent(v.id)}`, { spread: spreadInfo || null, author: v.createdBy, favKind: 'votes', isFavorite: v.isFavorite, reportTitle: v.question, returnTo, deleteAction: canModifyVote(v) ? contentDeleteAction('votes', v.id) : undefined });
|
|
|
|
return div({ class: "trending-card vote-card" + (isOwn ? " own-content" : "") },
|
|
headerActions ? div({ class: "card-header activity-card-header" },
|
|
span(),
|
|
headerActions
|
|
) : null,
|
|
div({ class: "card-section vote-card-body" },
|
|
div({ class: "shop-title-row" },
|
|
h2({ class: "tribe-card-title" },
|
|
href ? a({ href }, v.question || i18n.votationsTitle) : (v.question || i18n.votationsTitle)
|
|
)
|
|
),
|
|
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
|
v.deadline ? p({ class: "time-chip" }, moment(v.deadline).format("YYYY/MM/DD HH:mm")) : null,
|
|
origin ? null : div({ class: "tribe-card-members" },
|
|
span({ class: "tribe-members-count" }, `${i18n.eventAttendees}: ${totalVotesNum}`)
|
|
),
|
|
origin ? null : div({ class: `job-meta-line vote-outcome vote-outcome-${outcome.variant}` }, `${i18n.voteResults || "Results"}: ${outcome.text}`),
|
|
...(Array.isArray(opts.bodyExtra) ? opts.bodyExtra.filter(Boolean) : [])
|
|
)
|
|
);
|
|
};
|
|
|
|
exports.renderVoteOptionsBlock = (v, voteOptions, action, returnTo, canVote) => {
|
|
const baseCounts = voteOptions.reduce((acc, opt) => { acc[opt] = (v.votes && v.votes[opt]) ? v.votes[opt] : 0; return acc; }, {});
|
|
return [
|
|
canVote ? div({ class: "job-section" },
|
|
h2({ class: "job-section-title" }, i18n.voteCastTitle || "Cast Vote"),
|
|
div({ class: "vote-buttons-block" },
|
|
div({ class: "vote-buttons-row-single" },
|
|
...voteOptions.map((opt, idx) =>
|
|
form({ method: "POST", action },
|
|
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
|
|
input({ type: "hidden", name: "optionIndex", value: String(idx) }),
|
|
button({ type: "submit", name: "choice", value: opt }, voteLabel(opt))
|
|
)
|
|
)
|
|
)
|
|
)
|
|
) : null,
|
|
div({ class: "vote-table" },
|
|
table(
|
|
tr(...voteOptions.map((opt) => th(voteLabel(opt)))),
|
|
tr(...voteOptions.map((opt) => td(String(baseCounts[opt] || 0))))
|
|
)
|
|
)
|
|
];
|
|
};
|
|
|
|
const renderVoteDetail = (v, voteOptionsDefault, firstRow, secondRow, mode, activeFilter, params = {}) => {
|
|
const voteOptions = Array.isArray(v.options) && v.options.length ? v.options : voteOptionsDefault;
|
|
const baseCounts = voteOptions.reduce((acc, opt) => {
|
|
acc[opt] = (v.votes && v.votes[opt]) ? v.votes[opt] : 0;
|
|
return acc;
|
|
}, {});
|
|
|
|
const totalVotesNum = typeof v.totalVotes === "number" ? v.totalVotes : parseInt(String(v.totalVotes || "0"), 10) || 0;
|
|
const outcome = computeVoteOutcome(baseCounts, voteOptions, totalVotesNum);
|
|
|
|
const returnTo = `/votes/${encodeURIComponent(v.id)}?filter=${encodeURIComponent(activeFilter || "all")}`;
|
|
const chips = [
|
|
renderVoteStatusChip(v.status),
|
|
renderLifespanChip(v.lifetime, i18n),
|
|
renderEcoTax(v.msgSize, v.id)
|
|
].filter(Boolean);
|
|
|
|
const sideActions = [];
|
|
for (const a of renderVoteOwnerActions(v, returnTo)) sideActions.push(a);
|
|
|
|
const cleanTags = (Array.isArray(v.tags) ? v.tags : []).filter((t) => t && !String(t).includes(":"));
|
|
const tagsNode = cleanTags.length
|
|
? div({ class: "card-tags" },
|
|
cleanTags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
|
|
)
|
|
: null;
|
|
|
|
const infoRows = [];
|
|
const pushRow = (labelText, valueNode) =>
|
|
infoRows.push(tr(
|
|
td({ class: "tribe-info-label" }, labelText),
|
|
td({ class: "tribe-info-value" }, valueNode)
|
|
));
|
|
if (v.deadline) pushRow(i18n.voteDeadline, moment(v.deadline).format("YYYY/MM/DD HH:mm"));
|
|
pushRow(i18n.voteStatus, statusLabel(v.status));
|
|
|
|
const voteSide = div({ class: "tribe-side" },
|
|
div({ class: "card-header activity-card-header" },
|
|
renderContentActions(v.id, `/votes/${encodeURIComponent(v.id)}`, { spread: params.spreads || null, author: v.createdBy, favKind: 'votes', isFavorite: v.isFavorite, reportTitle: v.question, deleteAction: canModifyVote(v) ? contentDeleteAction('votes', v.id) : undefined })
|
|
),
|
|
div({ class: "shop-title-row" },
|
|
h2({ class: "tribe-card-title" }, v.question)
|
|
),
|
|
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
|
table({ class: "tribe-info-table jobs-info-table" }, ...infoRows, ...renderCreatedRows(v.createdAt, v.createdBy)),
|
|
tagsNode,
|
|
div({ class: "tribe-card-members" },
|
|
span({ class: "tribe-members-count" }, `${i18n.eventAttendees}: ${totalVotesNum}`)
|
|
),
|
|
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null,
|
|
renderDocumentActions('votes', v.id)
|
|
);
|
|
|
|
const voteButtonsNode = renderVoteButtons(v, voteOptions, firstRow, secondRow, returnTo);
|
|
|
|
const voteMain = div({ class: "tribe-main" },
|
|
mobileUi.isMobile() && !voteButtonsNode && isVoteAuthor(v) && normalizeStatus(v.status) === "OPEN" ? mobileUi.voteOwnNote() : null,
|
|
voteButtonsNode
|
|
? div({ class: "job-section" },
|
|
h2({ class: "job-section-title" }, i18n.voteCastTitle || "Cast Vote"),
|
|
voteButtonsNode
|
|
)
|
|
: null,
|
|
div({ class: "job-section" },
|
|
h2({ class: "job-section-title" },
|
|
`${i18n.voteResults || "Results"}: `,
|
|
span({ class: `vote-outcome vote-outcome-${outcome.variant}` }, outcome.text)
|
|
),
|
|
mobileUi.isMobile() ? mobileUi.voteBars(voteOptions, baseCounts, voteLabel) : div({ class: "vote-table" },
|
|
table(
|
|
tr(...voteOptions.map((opt) => th(voteLabel(opt)))),
|
|
tr(...voteOptions.map((opt) => td(baseCounts[opt])))
|
|
)
|
|
)
|
|
),
|
|
renderEngagement(v.id, renderOpinionsBar(v, returnTo),
|
|
mode === "detail" ? renderCommentsSection(v.id, params.comments || [], activeFilter) : null)
|
|
);
|
|
|
|
return div({ class: "tribe-details" }, voteSide, voteMain);
|
|
};
|
|
|
|
const renderCommentsSection = (voteId, comments, activeFilter) => {
|
|
const returnTo = `/votes/${encodeURIComponent(voteId)}?filter=${encodeURIComponent(activeFilter || "all")}`;
|
|
return renderSharedCommentsSection({
|
|
action: `/votes/${encodeURIComponent(voteId)}/comments`,
|
|
comments: comments,
|
|
returnTo: returnTo
|
|
});
|
|
};
|
|
|
|
exports.voteView = async (votes, mode, voteId, comments = [], activeFilterParam, params = {}) => {
|
|
const list = Array.isArray(votes) ? votes : [votes];
|
|
|
|
const standardFilters = ["all", "mine", "open", "closed"];
|
|
const activeFilter = standardFilters.includes(activeFilterParam)
|
|
? activeFilterParam
|
|
: (standardFilters.includes(mode) ? mode : "all");
|
|
|
|
const title = i18n.votationsTitle;
|
|
|
|
const voteToEdit = list.find((v) => v.id === voteId) || {};
|
|
const editTags = Array.isArray(voteToEdit.tags) ? voteToEdit.tags.filter(Boolean) : [];
|
|
|
|
let filtered =
|
|
mode === "mine" ? list.filter((v) => v.createdBy === userId) :
|
|
mode === "open" ? list.filter((v) => normalizeStatus(v.status) === "OPEN") :
|
|
mode === "closed" ? list.filter((v) => normalizeStatus(v.status) === "CLOSED") :
|
|
list;
|
|
|
|
filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
|
|
const voteOptions = ["ABSTENTION", "YES", "NO", "CONFUSED", "FOLLOW_MAJORITY", "NOT_INTERESTED"];
|
|
const firstRow = ["ABSTENTION", "YES", "NO"];
|
|
const secondRow = ["CONFUSED", "FOLLOW_MAJORITY", "NOT_INTERESTED"];
|
|
|
|
const header = div(
|
|
{ class: "tags-header module-header-line" },
|
|
h2(i18n.votationsTitle),
|
|
p(i18n.votationsDescription)
|
|
);
|
|
|
|
const listReturnTo = standardFilters.includes(activeFilter) ? `/votes?filter=${encodeURIComponent(activeFilter)}` : "/votes";
|
|
const emptyMod = moduleIsEmpty(filtered, mode, "all", (params && params.q) || "");
|
|
const censusV = Array.isArray(params && params.censusList) ? params.censusList : list;
|
|
const voteChip = (m) => {
|
|
if (m === mode) return true;
|
|
if (m === "mine") return censusV.some((v) => v.createdBy === userId);
|
|
if (m === "recent") return censusV.length > 0;
|
|
if (m === "open") return censusV.some((v) => normalizeStatus(v.status) === "OPEN");
|
|
if (m === "closed") return censusV.some((v) => normalizeStatus(v.status) === "CLOSED");
|
|
return true;
|
|
};
|
|
|
|
const minVoteDays = Number(params.minVoteDays) > 0 ? Number(params.minVoteDays) : 7;
|
|
const deadlineMin = moment().add(minVoteDays, "days").format("YYYY-MM-DDTHH:mm");
|
|
const draft = params.draft || {};
|
|
const deadlineValue = draft.deadline
|
|
? String(draft.deadline)
|
|
: (voteToEdit.deadline ? moment(voteToEdit.deadline).format("YYYY-MM-DDTHH:mm") : "");
|
|
const questionValue = draft.question !== undefined && draft.question !== "" ? String(draft.question) : (voteToEdit.question || "");
|
|
const tagsValue = draft.tags ? String(draft.tags) : editTags.join(", ");
|
|
|
|
return template(
|
|
title,
|
|
section(
|
|
header,
|
|
div(
|
|
{ class: "filters" },
|
|
form(
|
|
{ method: "GET", action: "/votes" },
|
|
...(emptyMod ? [] : [
|
|
button({ type: "submit", name: "filter", value: "all", class: mode === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.voteFilterAll).toUpperCase()),
|
|
...(voteChip("recent") ? [button({ type: "submit", name: "filter", value: "recent", class: mode === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.voteFilterRecent).toUpperCase())] : []),
|
|
...(voteChip("mine") ? [button({ type: "submit", name: "filter", value: "mine", class: mode === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.voteFilterMine).toUpperCase())] : []),
|
|
...(voteChip("open") ? [button({ type: "submit", name: "filter", value: "open", class: mode === "open" ? "filter-btn active" : "filter-btn" }, String(i18n.voteFilterOpen).toUpperCase())] : []),
|
|
...(voteChip("closed") ? [button({ type: "submit", name: "filter", value: "closed", class: mode === "closed" ? "filter-btn active" : "filter-btn" }, String(i18n.voteFilterClosed).toUpperCase())] : []),
|
|
]),
|
|
button({ type: "submit", name: "filter", value: "create", class: mode === "create" ? "create-button active" : "create-button" }, i18n.voteCreateButton)
|
|
)
|
|
),
|
|
mode !== "create" && mode !== "edit" && !emptyMod
|
|
? div({ class: "filters activity-filter-chips activity-toolbar-row" },
|
|
renderModuleStatsBy(filtered, v => normalizeStatus(v.status || 'OPEN'), [{ value: 'OPEN', label: i18n.voteStatusOpen }, { value: 'CLOSED', label: i18n.voteStatusClosed }]),
|
|
form({ method: "GET", action: "/votes", class: "filter-box" },
|
|
input({ type: "hidden", name: "filter", value: mode }),
|
|
input({ type: "text", name: "q", value: (params && params.q) || "", placeholder: i18n.votesSearchPlaceholder, class: "filter-box__input" }),
|
|
div({ class: "filter-box__controls" },
|
|
button({ type: "submit", class: "filter-box__button" }, i18n.searchButton)
|
|
)
|
|
)
|
|
)
|
|
: null
|
|
),
|
|
section(
|
|
(mode === "edit" || mode === "create")
|
|
? div(
|
|
{ class: "vote-form" },
|
|
mode === "edit" ? await renderSpreadEditWarning(voteId) : null,
|
|
form(
|
|
{ action: mode === "edit" ? `/votes/update/${encodeURIComponent(voteId)}` : "/votes/create", method: "POST" },
|
|
input({ type: "hidden", name: "returnTo", value: listReturnTo }),
|
|
h2(i18n.voteQuestionLabel),
|
|
input({ type: "text", name: "question", id: "question", required: true, value: questionValue }), br(), br(),
|
|
label(i18n.voteDeadlineLabel), br(),
|
|
input({
|
|
type: "datetime-local",
|
|
name: "deadline",
|
|
id: "deadline",
|
|
required: true,
|
|
min: deadlineMin,
|
|
value: deadlineValue
|
|
}), br(), br(),
|
|
label(i18n.voteTagsLabel), br(),
|
|
input({ type: "text", name: "tags", id: "tags", value: tagsValue }), br(), br(),
|
|
mobileUi.isMobile() && mode === "create" ? mobileUi.voteKindPicker(draft.options) : null,
|
|
button({ type: "submit" }, mode === "edit" ? i18n.voteUpdateButton : i18n.voteCreateButton)
|
|
)
|
|
)
|
|
: mode === "detail" && voteId
|
|
? renderVoteDetail(filtered[0] || list.find(v => v.id === voteId) || {}, voteOptions, firstRow, secondRow, mode, activeFilter, { ...params, comments })
|
|
: filtered.length > 0
|
|
? div({ class: "jobs-grid" }, paged(filtered).map((v) => renderVoteListItem(v, voteOptions, activeFilter, params.spreadMap && params.spreadMap.get(v.id))))
|
|
: div({ class: "no-content-box" }, p(i18n.novotes)),
|
|
)
|
|
);
|
|
};
|
|
|