The card can rate an order but cannot ask which dish or why: a message cannot collect free text, and an actions block caps at 25 elements while item counts vary per receipt. A modal is the only Slack-native answer, and it stays inside Slack — no browser, no app, which is the whole reason it exists rather than a link. The overall rating deliberately stays on the card. That is the thing done every time and it should cost one tap; this is for when something was notably good or bad. finance-app holds no Slack bot token by design, so it returns the view and n8n — which already has the credential — calls views.open. One copy of the token, no new secret, no compose change. Item text travels in private_metadata because a submission returns block ids and values, never labels, so there is otherwise no way back to which dish a radio button referred to. Capped at 20 rows: a grocery order runs long and nobody scrolls a modal to rate a tin of tomatoes. The modal does NOT write the rating. A form that silently reset a decision the user did not revisit is the same class of bug as the split rewrite that dropped `settled`.
This commit is contained in:
@@ -100,5 +100,123 @@ export function nudgeBlocks(s: NudgeState) {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "details",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
action_id: "open_details",
|
||||
text: { type: "plain_text", text: "Add details" },
|
||||
value: `${s.transactionId}:details`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-item verdicts and the free-text note, as a Slack modal.
|
||||
*
|
||||
* A message cannot collect free text and an actions block caps at 25 elements,
|
||||
* so this is the only Slack-native way to ask "which dish, and why". It stays
|
||||
* inside Slack — no browser, no app — which is the entire reason it exists
|
||||
* rather than a link.
|
||||
*
|
||||
* The overall rating deliberately stays on the card: it is the thing you do
|
||||
* every time and it should cost one tap. This is for the times something was
|
||||
* notably good or bad.
|
||||
*
|
||||
* `private_metadata` carries the ids because a view_submission arrives as a
|
||||
* fresh request with no reference to the message it came from.
|
||||
*/
|
||||
export function detailsModal(
|
||||
transactionId: number,
|
||||
participantId: number,
|
||||
merchant: string,
|
||||
items: string[],
|
||||
existing: { note?: string | null; itemVerdicts?: { item: string; verdict: string }[] }
|
||||
) {
|
||||
const verdictOf = (item: string) =>
|
||||
existing.itemVerdicts?.find(
|
||||
(v) => v.item.trim().toLowerCase() === item.trim().toLowerCase()
|
||||
)?.verdict ?? null;
|
||||
|
||||
const opt = (text: string, value: string) => ({
|
||||
text: { type: "plain_text", text },
|
||||
value,
|
||||
});
|
||||
|
||||
const itemBlocks = items
|
||||
// Slack allows 100 blocks per view; a grocery order can be long, and past
|
||||
// ~20 rows nobody is scrolling a modal to rate a tin of tomatoes anyway.
|
||||
.slice(0, 20)
|
||||
.map((item, i) => {
|
||||
const current = verdictOf(item);
|
||||
const options = [opt("👍 Great", "loved"), opt("👎 Never again", "never")];
|
||||
return {
|
||||
type: "input",
|
||||
block_id: `item_${i}`,
|
||||
optional: true,
|
||||
// The label carries the item name; Slack truncates at 150 chars.
|
||||
label: { type: "plain_text", text: item.slice(0, 150) },
|
||||
element: {
|
||||
type: "radio_buttons",
|
||||
action_id: "verdict",
|
||||
options,
|
||||
...(current
|
||||
? { initial_option: options.find((o) => o.value === current) }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
type: "modal",
|
||||
callback_id: "order_details",
|
||||
private_metadata: JSON.stringify({
|
||||
t: transactionId,
|
||||
p: participantId,
|
||||
// The item text is not recoverable from the submission — Slack returns
|
||||
// block ids and values, not labels — so it travels with the view.
|
||||
i: items.slice(0, 20),
|
||||
}),
|
||||
title: { type: "plain_text", text: "Order details" },
|
||||
submit: { type: "plain_text", text: "Save" },
|
||||
close: { type: "plain_text", text: "Cancel" },
|
||||
blocks: [
|
||||
{ type: "section", text: { type: "mrkdwn", text: `*${merchant}*` } },
|
||||
{
|
||||
type: "input",
|
||||
block_id: "note",
|
||||
optional: true,
|
||||
label: { type: "plain_text", text: "Anything worth remembering?" },
|
||||
element: {
|
||||
type: "plain_text_input",
|
||||
action_id: "value",
|
||||
multiline: true,
|
||||
initial_value: existing.note ?? undefined,
|
||||
placeholder: {
|
||||
type: "plain_text",
|
||||
text: "e.g. the biryani was decent, sides were cold",
|
||||
},
|
||||
},
|
||||
},
|
||||
...(itemBlocks.length
|
||||
? [{ type: "divider" }, ...itemBlocks]
|
||||
: [
|
||||
{
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
// Uber itemises groceries but not restaurant orders, so an
|
||||
// empty list is the receipt, not a failure.
|
||||
text: "_This receipt has no itemised list._",
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user