fix(approvals): usertest-panel hardening (0.81.0)
Panel findings against the reworked approvals page, fixed in place: - Never fabricate the request time: ApprovalRecord.createdAt is nullable now; a missing created_at omits the line instead of rendering DateTime.now() (which drifted on refresh). Guard: approvals_origin_test pins the omit-on-null invariant. - Copyable errors on approve/reject/batch via showChainErrorSnack (the hard project rule) — batch surfaces the first real cause. - Reject requires a reason: ChainInlineHelp strip + confirm disabled while empty, no more silent close-and-nothing-happens. - Batch approve applies the same no-data confirmation as the single path, naming how many selected requests carry no show: data. - Plainer language: glossary "Vorgang (Flow)", history label FRAGE (was PROMPT), no-data hint drops developer jargon. - One-click copy of the run id; history payload pretty-prints like the card. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
28f6fe1a9a
commit
f7d7427d91
9 changed files with 411 additions and 122 deletions
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -28,6 +28,31 @@ lockstep.
|
||||||
local CLI falls back to it. Guard: two new `RunsLoadErrorView`
|
local CLI falls back to it. Guard: two new `RunsLoadErrorView`
|
||||||
widget tests.
|
widget tests.
|
||||||
|
|
||||||
|
### Fixed (0.81.0)
|
||||||
|
|
||||||
|
Approvals usertest-panel hardening:
|
||||||
|
|
||||||
|
- **Never fake the request time.** A missing/unparseable `created_at`
|
||||||
|
now leaves the "Angefordert" line off the card instead of showing
|
||||||
|
`now` (which even drifted on every refresh). `ApprovalRecord.createdAt`
|
||||||
|
is nullable; guard in `approvals_origin_test`.
|
||||||
|
- **Copyable errors on every path.** Approve/Reject and both batch
|
||||||
|
actions now route failures through `showChainErrorSnack` instead of
|
||||||
|
a bare `SnackBar(Text(e))` — the batch path surfaces the first real
|
||||||
|
cause, not just a count.
|
||||||
|
- **Reject needs a reason.** The reject dialog leads with a
|
||||||
|
`ChainInlineHelp` strip and disables the confirm button while the
|
||||||
|
reason is empty, instead of closing silently with nothing happening.
|
||||||
|
- **Batch approve respects the no-data guard.** A bulk approve that
|
||||||
|
includes requests without `show:` data now asks for the same
|
||||||
|
conscious confirmation the single approve already required, naming
|
||||||
|
how many are data-less.
|
||||||
|
- **Plainer language.** The intro and origin glossary "Flow" as
|
||||||
|
"Vorgang (Flow)", the history detail labels the question "FRAGE"
|
||||||
|
(not "PROMPT"), and the no-data hint drops developer jargon.
|
||||||
|
- **Copy the run id in one click**, and the history payload renders
|
||||||
|
with the same JSON pretty-printing as the card.
|
||||||
|
|
||||||
### Added (0.80.0)
|
### Added (0.80.0)
|
||||||
|
|
||||||
- **Ollama joins the host services.** Ollama-backed store entries
|
- **Ollama joins the host services.** Ollama-backed store entries
|
||||||
|
|
|
||||||
|
|
@ -1152,7 +1152,13 @@ class HubService {
|
||||||
stepId: e.stepId,
|
stepId: e.stepId,
|
||||||
prompt: e.prompt,
|
prompt: e.prompt,
|
||||||
payloadPreview: e.payloadPreview.isEmpty ? null : e.payloadPreview,
|
payloadPreview: e.payloadPreview.isEmpty ? null : e.payloadPreview,
|
||||||
createdAt: DateTime.tryParse(e.createdAt) ?? DateTime.now(),
|
// Never fabricate the request time: a missing / unparseable
|
||||||
|
// created_at stays null so the card can omit the line
|
||||||
|
// instead of showing "now" (which would even change on
|
||||||
|
// every refresh) as if it were the truth.
|
||||||
|
createdAt: e.createdAt.isEmpty
|
||||||
|
? null
|
||||||
|
: DateTime.tryParse(e.createdAt),
|
||||||
expiresAt: e.expiresAt.isEmpty
|
expiresAt: e.expiresAt.isEmpty
|
||||||
? null
|
? null
|
||||||
: DateTime.tryParse(e.expiresAt),
|
: DateTime.tryParse(e.expiresAt),
|
||||||
|
|
@ -1762,7 +1768,11 @@ class ApprovalRecord {
|
||||||
final String stepId;
|
final String stepId;
|
||||||
final String prompt;
|
final String prompt;
|
||||||
final String? payloadPreview;
|
final String? payloadPreview;
|
||||||
final DateTime createdAt;
|
|
||||||
|
/// When the approval was requested. Null when the hub sent no
|
||||||
|
/// parseable timestamp — the card omits the line rather than
|
||||||
|
/// inventing one.
|
||||||
|
final DateTime? createdAt;
|
||||||
final DateTime? expiresAt;
|
final DateTime? expiresAt;
|
||||||
|
|
||||||
/// One of: pending / approved / rejected / expired.
|
/// One of: pending / approved / rejected / expired.
|
||||||
|
|
|
||||||
|
|
@ -1095,13 +1095,20 @@
|
||||||
"approvalsPillRejected": "abgelehnt",
|
"approvalsPillRejected": "abgelehnt",
|
||||||
"approvalsPillExpired": "abgelaufen",
|
"approvalsPillExpired": "abgelaufen",
|
||||||
"approvalsPayloadPreview": "ZU PRÜFENDE DATEN",
|
"approvalsPayloadPreview": "ZU PRÜFENDE DATEN",
|
||||||
"approvalsNoPayload": "Keine Daten zum Prüfen angehängt. Der system.approval-Schritt des Flows bestimmt über sein \"show:\"-Feld, was angezeigt wird — setzen Sie es, um die Daten hinter dieser Entscheidung sichtbar zu machen.",
|
"approvalsNoPayload": "Zu dieser Freigabe wurden keine Daten hinterlegt. Sie entscheiden ohne Detailansicht — fragen Sie im Zweifel die Person, die den Vorgang eingerichtet hat. (Technisch: das Feld \"show:\" des Freigabe-Schritts legt fest, was hier erscheint.)",
|
||||||
"approvalsNoDataConfirmTitle": "Ohne Prüfdaten freigeben?",
|
"approvalsNoDataConfirmTitle": "Ohne Prüfdaten freigeben?",
|
||||||
"approvalsNoDataConfirmBody": "Dieser Flow hat bewusst keine Prüfdaten hinterlegt (kein \"show:\" am Freigabe-Schritt). Sie können trotzdem freigeben — entscheiden dann aber, ohne die Daten hinter dieser Entscheidung gesehen zu haben.",
|
"approvalsNoDataConfirmBody": "Dieser Flow hat bewusst keine Prüfdaten hinterlegt (kein \"show:\" am Freigabe-Schritt). Sie können trotzdem freigeben — entscheiden dann aber, ohne die Daten hinter dieser Entscheidung gesehen zu haben.",
|
||||||
"approvalsNoDataConfirmAction": "Trotzdem freigeben",
|
"approvalsNoDataConfirmAction": "Trotzdem freigeben",
|
||||||
"approvalsRequestFallback": "Freigabe für diesen Schritt erforderlich",
|
"approvalsRequestFallback": "Freigabe für diesen Schritt erforderlich",
|
||||||
"approvalsFlowStepMeta": "Flow: {flow} · Schritt: {step}",
|
"approvalsFlowStepMeta": "Flow: {flow} · Schritt: {step}",
|
||||||
"approvalsIntroHelp": "Hier warten pausierte Flows auf Ihre Entscheidung. Jede Karte zeigt, welcher Flow an welchem Schritt hält und welche Daten er Ihnen vorlegt — Freigeben setzt den Flow fort, Ablehnen stoppt ihn mit Ihrer Begründung.",
|
"approvalsIntroHelp": "Hier warten pausierte Vorgänge (Flows) auf Ihre Entscheidung. Jede Karte zeigt, welcher Vorgang an welchem Schritt hält und welche Daten er Ihnen vorlegt — Freigeben setzt ihn fort, Ablehnen stoppt ihn mit Ihrer Begründung.",
|
||||||
|
"approvalsRejectDialogHelp": "Die Ablehnung stoppt den Vorgang an diesem Schritt und wird mit Ihrer Begründung im Prüfprotokoll festgehalten.",
|
||||||
|
"approvalsRejectReasonHelper": "Pflichtfeld — wird im Prüfprotokoll (Audit-Log) festgehalten.",
|
||||||
|
"approvalsBatchNoDataTitle": "Ohne Prüfdaten freigeben?",
|
||||||
|
"approvalsBatchNoDataBody": "{count, plural, =1{Von {total} ausgewählten Freigaben hat 1 keine Prüfdaten. Sie geben sie ohne Detailansicht frei.} other{Von {total} ausgewählten Freigaben haben {count} keine Prüfdaten. Sie geben sie ohne Detailansicht frei.}}",
|
||||||
|
"approvalsCopyRun": "Lauf-Kennung kopieren",
|
||||||
|
"approvalsRunCopied": "Lauf-Kennung kopiert",
|
||||||
|
"approvalsUnknownReviewer": "(unbekannt)",
|
||||||
"approvalsOriginLabel": "HERKUNFT",
|
"approvalsOriginLabel": "HERKUNFT",
|
||||||
"approvalsOriginProject": "Projekt",
|
"approvalsOriginProject": "Projekt",
|
||||||
"approvalsOriginRequested": "Angefordert",
|
"approvalsOriginRequested": "Angefordert",
|
||||||
|
|
@ -1111,7 +1118,7 @@
|
||||||
"approvalsApproveButton": "Freigeben",
|
"approvalsApproveButton": "Freigeben",
|
||||||
"approvalsRejectButton": "Ablehnen",
|
"approvalsRejectButton": "Ablehnen",
|
||||||
"approvalsRejectDialogTitle": "Freigabe ablehnen",
|
"approvalsRejectDialogTitle": "Freigabe ablehnen",
|
||||||
"approvalsRejectReasonLabel": "Begründung (im Audit-Log festgehalten)",
|
"approvalsRejectReasonLabel": "Begründung",
|
||||||
"approvalsApprovedToast": "freigegeben · {flow} › {step}",
|
"approvalsApprovedToast": "freigegeben · {flow} › {step}",
|
||||||
"@approvalsApprovedToast": {
|
"@approvalsApprovedToast": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|
@ -1155,7 +1162,7 @@
|
||||||
"approvalsDialogDecided": "entschieden",
|
"approvalsDialogDecided": "entschieden",
|
||||||
"approvalsDialogCreated": "erstellt",
|
"approvalsDialogCreated": "erstellt",
|
||||||
"approvalsDialogReason": "Begründung",
|
"approvalsDialogReason": "Begründung",
|
||||||
"approvalsDialogPrompt": "PROMPT",
|
"approvalsDialogPrompt": "FRAGE",
|
||||||
"approvalsExpiresIn": "{minutes}m",
|
"approvalsExpiresIn": "{minutes}m",
|
||||||
"@approvalsExpiresIn": {
|
"@approvalsExpiresIn": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|
|
||||||
|
|
@ -1113,13 +1113,20 @@
|
||||||
"approvalsPillRejected": "rejected",
|
"approvalsPillRejected": "rejected",
|
||||||
"approvalsPillExpired": "expired",
|
"approvalsPillExpired": "expired",
|
||||||
"approvalsPayloadPreview": "DATA TO REVIEW",
|
"approvalsPayloadPreview": "DATA TO REVIEW",
|
||||||
"approvalsNoPayload": "No data was attached for review. The flow's approval step chooses what to show via its \"show:\" field — set it to surface the data behind this decision.",
|
"approvalsNoPayload": "No data was attached to this approval. You are deciding without a detail view — when in doubt, ask whoever set up the process. (Technical: the approval step's \"show:\" field controls what appears here.)",
|
||||||
"approvalsNoDataConfirmTitle": "Approve without review data?",
|
"approvalsNoDataConfirmTitle": "Approve without review data?",
|
||||||
"approvalsNoDataConfirmBody": "This flow deliberately attached no review data (no \"show:\" on its approval step). You can still approve — but you would be deciding without seeing the data behind this decision.",
|
"approvalsNoDataConfirmBody": "This flow deliberately attached no review data (no \"show:\" on its approval step). You can still approve — but you would be deciding without seeing the data behind this decision.",
|
||||||
"approvalsNoDataConfirmAction": "Approve anyway",
|
"approvalsNoDataConfirmAction": "Approve anyway",
|
||||||
"approvalsRequestFallback": "Approval required for this step",
|
"approvalsRequestFallback": "Approval required for this step",
|
||||||
"approvalsFlowStepMeta": "Flow: {flow} · Step: {step}",
|
"approvalsFlowStepMeta": "Flow: {flow} · Step: {step}",
|
||||||
"approvalsIntroHelp": "Paused flows waiting for your decision. Each card shows which flow is holding at which step and what data it puts in front of you — Approve resumes the flow, Reject stops it with your reason.",
|
"approvalsIntroHelp": "Paused processes (flows) waiting for your decision. Each card shows which process is holding at which step and what data it puts in front of you — Approve resumes it, Reject stops it with your reason.",
|
||||||
|
"approvalsRejectDialogHelp": "Rejecting stops the process at this step and is recorded with your reason in the audit trail.",
|
||||||
|
"approvalsRejectReasonHelper": "Required — recorded in the audit log.",
|
||||||
|
"approvalsBatchNoDataTitle": "Approve without review data?",
|
||||||
|
"approvalsBatchNoDataBody": "{count, plural, =1{1 of {total} selected approvals has no review data. You are approving it without a detail view.} other{{count} of {total} selected approvals have no review data. You are approving them without a detail view.}}",
|
||||||
|
"approvalsCopyRun": "Copy run id",
|
||||||
|
"approvalsRunCopied": "Run id copied",
|
||||||
|
"approvalsUnknownReviewer": "(unknown)",
|
||||||
"approvalsOriginLabel": "ORIGIN",
|
"approvalsOriginLabel": "ORIGIN",
|
||||||
"approvalsOriginProject": "Project",
|
"approvalsOriginProject": "Project",
|
||||||
"approvalsOriginRequested": "Requested",
|
"approvalsOriginRequested": "Requested",
|
||||||
|
|
@ -1135,7 +1142,7 @@
|
||||||
"approvalsApproveButton": "Approve",
|
"approvalsApproveButton": "Approve",
|
||||||
"approvalsRejectButton": "Reject",
|
"approvalsRejectButton": "Reject",
|
||||||
"approvalsRejectDialogTitle": "Reject approval",
|
"approvalsRejectDialogTitle": "Reject approval",
|
||||||
"approvalsRejectReasonLabel": "Reason (recorded in audit log)",
|
"approvalsRejectReasonLabel": "Reason",
|
||||||
"approvalsApprovedToast": "approved · {flow} › {step}",
|
"approvalsApprovedToast": "approved · {flow} › {step}",
|
||||||
"@approvalsApprovedToast": {
|
"@approvalsApprovedToast": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|
@ -1179,7 +1186,7 @@
|
||||||
"approvalsDialogDecided": "decided",
|
"approvalsDialogDecided": "decided",
|
||||||
"approvalsDialogCreated": "created",
|
"approvalsDialogCreated": "created",
|
||||||
"approvalsDialogReason": "reason",
|
"approvalsDialogReason": "reason",
|
||||||
"approvalsDialogPrompt": "PROMPT",
|
"approvalsDialogPrompt": "QUESTION",
|
||||||
"approvalsExpiresIn": "{minutes}m",
|
"approvalsExpiresIn": "{minutes}m",
|
||||||
"@approvalsExpiresIn": {
|
"@approvalsExpiresIn": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|
|
||||||
|
|
@ -3521,7 +3521,7 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @approvalsNoPayload.
|
/// No description provided for @approvalsNoPayload.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'No data was attached for review. The flow\'s approval step chooses what to show via its \"show:\" field — set it to surface the data behind this decision.'**
|
/// **'No data was attached to this approval. You are deciding without a detail view — when in doubt, ask whoever set up the process. (Technical: the approval step\'s \"show:\" field controls what appears here.)'**
|
||||||
String get approvalsNoPayload;
|
String get approvalsNoPayload;
|
||||||
|
|
||||||
/// No description provided for @approvalsNoDataConfirmTitle.
|
/// No description provided for @approvalsNoDataConfirmTitle.
|
||||||
|
|
@ -3557,9 +3557,51 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @approvalsIntroHelp.
|
/// No description provided for @approvalsIntroHelp.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Paused flows waiting for your decision. Each card shows which flow is holding at which step and what data it puts in front of you — Approve resumes the flow, Reject stops it with your reason.'**
|
/// **'Paused processes (flows) waiting for your decision. Each card shows which process is holding at which step and what data it puts in front of you — Approve resumes it, Reject stops it with your reason.'**
|
||||||
String get approvalsIntroHelp;
|
String get approvalsIntroHelp;
|
||||||
|
|
||||||
|
/// No description provided for @approvalsRejectDialogHelp.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Rejecting stops the process at this step and is recorded with your reason in the audit trail.'**
|
||||||
|
String get approvalsRejectDialogHelp;
|
||||||
|
|
||||||
|
/// No description provided for @approvalsRejectReasonHelper.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Required — recorded in the audit log.'**
|
||||||
|
String get approvalsRejectReasonHelper;
|
||||||
|
|
||||||
|
/// No description provided for @approvalsBatchNoDataTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Approve without review data?'**
|
||||||
|
String get approvalsBatchNoDataTitle;
|
||||||
|
|
||||||
|
/// No description provided for @approvalsBatchNoDataBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1{1 of {total} selected approvals has no review data. You are approving it without a detail view.} other{{count} of {total} selected approvals have no review data. You are approving them without a detail view.}}'**
|
||||||
|
String approvalsBatchNoDataBody(num count, Object total);
|
||||||
|
|
||||||
|
/// No description provided for @approvalsCopyRun.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Copy run id'**
|
||||||
|
String get approvalsCopyRun;
|
||||||
|
|
||||||
|
/// No description provided for @approvalsRunCopied.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Run id copied'**
|
||||||
|
String get approvalsRunCopied;
|
||||||
|
|
||||||
|
/// No description provided for @approvalsUnknownReviewer.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'(unknown)'**
|
||||||
|
String get approvalsUnknownReviewer;
|
||||||
|
|
||||||
/// No description provided for @approvalsOriginLabel.
|
/// No description provided for @approvalsOriginLabel.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -3617,7 +3659,7 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @approvalsRejectReasonLabel.
|
/// No description provided for @approvalsRejectReasonLabel.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Reason (recorded in audit log)'**
|
/// **'Reason'**
|
||||||
String get approvalsRejectReasonLabel;
|
String get approvalsRejectReasonLabel;
|
||||||
|
|
||||||
/// No description provided for @approvalsApprovedToast.
|
/// No description provided for @approvalsApprovedToast.
|
||||||
|
|
@ -3677,7 +3719,7 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @approvalsDialogPrompt.
|
/// No description provided for @approvalsDialogPrompt.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'PROMPT'**
|
/// **'QUESTION'**
|
||||||
String get approvalsDialogPrompt;
|
String get approvalsDialogPrompt;
|
||||||
|
|
||||||
/// No description provided for @approvalsExpiresIn.
|
/// No description provided for @approvalsExpiresIn.
|
||||||
|
|
|
||||||
|
|
@ -2037,7 +2037,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsNoPayload =>
|
String get approvalsNoPayload =>
|
||||||
'Keine Daten zum Prüfen angehängt. Der system.approval-Schritt des Flows bestimmt über sein \"show:\"-Feld, was angezeigt wird — setzen Sie es, um die Daten hinter dieser Entscheidung sichtbar zu machen.';
|
'Zu dieser Freigabe wurden keine Daten hinterlegt. Sie entscheiden ohne Detailansicht — fragen Sie im Zweifel die Person, die den Vorgang eingerichtet hat. (Technisch: das Feld \"show:\" des Freigabe-Schritts legt fest, was hier erscheint.)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsNoDataConfirmTitle => 'Ohne Prüfdaten freigeben?';
|
String get approvalsNoDataConfirmTitle => 'Ohne Prüfdaten freigeben?';
|
||||||
|
|
@ -2060,7 +2060,40 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsIntroHelp =>
|
String get approvalsIntroHelp =>
|
||||||
'Hier warten pausierte Flows auf Ihre Entscheidung. Jede Karte zeigt, welcher Flow an welchem Schritt hält und welche Daten er Ihnen vorlegt — Freigeben setzt den Flow fort, Ablehnen stoppt ihn mit Ihrer Begründung.';
|
'Hier warten pausierte Vorgänge (Flows) auf Ihre Entscheidung. Jede Karte zeigt, welcher Vorgang an welchem Schritt hält und welche Daten er Ihnen vorlegt — Freigeben setzt ihn fort, Ablehnen stoppt ihn mit Ihrer Begründung.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsRejectDialogHelp =>
|
||||||
|
'Die Ablehnung stoppt den Vorgang an diesem Schritt und wird mit Ihrer Begründung im Prüfprotokoll festgehalten.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsRejectReasonHelper =>
|
||||||
|
'Pflichtfeld — wird im Prüfprotokoll (Audit-Log) festgehalten.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsBatchNoDataTitle => 'Ohne Prüfdaten freigeben?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String approvalsBatchNoDataBody(num count, Object total) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other:
|
||||||
|
'Von $total ausgewählten Freigaben haben $count keine Prüfdaten. Sie geben sie ohne Detailansicht frei.',
|
||||||
|
one:
|
||||||
|
'Von $total ausgewählten Freigaben hat 1 keine Prüfdaten. Sie geben sie ohne Detailansicht frei.',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsCopyRun => 'Lauf-Kennung kopieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsRunCopied => 'Lauf-Kennung kopiert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsUnknownReviewer => '(unbekannt)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsOriginLabel => 'HERKUNFT';
|
String get approvalsOriginLabel => 'HERKUNFT';
|
||||||
|
|
@ -2090,8 +2123,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get approvalsRejectDialogTitle => 'Freigabe ablehnen';
|
String get approvalsRejectDialogTitle => 'Freigabe ablehnen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsRejectReasonLabel =>
|
String get approvalsRejectReasonLabel => 'Begründung';
|
||||||
'Begründung (im Audit-Log festgehalten)';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String approvalsApprovedToast(String flow, String step) {
|
String approvalsApprovedToast(String flow, String step) {
|
||||||
|
|
@ -2130,7 +2162,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get approvalsDialogReason => 'Begründung';
|
String get approvalsDialogReason => 'Begründung';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsDialogPrompt => 'PROMPT';
|
String get approvalsDialogPrompt => 'FRAGE';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String approvalsExpiresIn(int minutes) {
|
String approvalsExpiresIn(int minutes) {
|
||||||
|
|
|
||||||
|
|
@ -2042,7 +2042,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsNoPayload =>
|
String get approvalsNoPayload =>
|
||||||
'No data was attached for review. The flow\'s approval step chooses what to show via its \"show:\" field — set it to surface the data behind this decision.';
|
'No data was attached to this approval. You are deciding without a detail view — when in doubt, ask whoever set up the process. (Technical: the approval step\'s \"show:\" field controls what appears here.)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsNoDataConfirmTitle => 'Approve without review data?';
|
String get approvalsNoDataConfirmTitle => 'Approve without review data?';
|
||||||
|
|
@ -2064,7 +2064,40 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsIntroHelp =>
|
String get approvalsIntroHelp =>
|
||||||
'Paused flows waiting for your decision. Each card shows which flow is holding at which step and what data it puts in front of you — Approve resumes the flow, Reject stops it with your reason.';
|
'Paused processes (flows) waiting for your decision. Each card shows which process is holding at which step and what data it puts in front of you — Approve resumes it, Reject stops it with your reason.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsRejectDialogHelp =>
|
||||||
|
'Rejecting stops the process at this step and is recorded with your reason in the audit trail.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsRejectReasonHelper =>
|
||||||
|
'Required — recorded in the audit log.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsBatchNoDataTitle => 'Approve without review data?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String approvalsBatchNoDataBody(num count, Object total) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other:
|
||||||
|
'$count of $total selected approvals have no review data. You are approving them without a detail view.',
|
||||||
|
one:
|
||||||
|
'1 of $total selected approvals has no review data. You are approving it without a detail view.',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsCopyRun => 'Copy run id';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsRunCopied => 'Run id copied';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsUnknownReviewer => '(unknown)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsOriginLabel => 'ORIGIN';
|
String get approvalsOriginLabel => 'ORIGIN';
|
||||||
|
|
@ -2094,7 +2127,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get approvalsRejectDialogTitle => 'Reject approval';
|
String get approvalsRejectDialogTitle => 'Reject approval';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsRejectReasonLabel => 'Reason (recorded in audit log)';
|
String get approvalsRejectReasonLabel => 'Reason';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String approvalsApprovedToast(String flow, String step) {
|
String approvalsApprovedToast(String flow, String step) {
|
||||||
|
|
@ -2133,7 +2166,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get approvalsDialogReason => 'reason';
|
String get approvalsDialogReason => 'reason';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsDialogPrompt => 'PROMPT';
|
String get approvalsDialogPrompt => 'QUESTION';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String approvalsExpiresIn(int minutes) {
|
String approvalsExpiresIn(int minutes) {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../data/error_presentation.dart';
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
import '../data/workspace.dart';
|
import '../data/workspace.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
@ -129,7 +131,9 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||||
_toast(l.approvalsApprovedToast(a.flowName, a.stepId));
|
_toast(l.approvalsApprovedToast(a.flowName, a.stepId));
|
||||||
_refresh();
|
_refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_toast(l.approvalsApproveFailed(e.toString()));
|
if (!mounted) return;
|
||||||
|
// Copyable error — never a bare SnackBar(Text(e)).
|
||||||
|
showChainErrorSnack(context, 'approvals.approve', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,7 +146,8 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||||
_toast(l.approvalsRejectedToast(a.flowName, a.stepId));
|
_toast(l.approvalsRejectedToast(a.flowName, a.stepId));
|
||||||
_refresh();
|
_refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_toast(l.approvalsRejectFailed(e.toString()));
|
if (!mounted) return;
|
||||||
|
showChainErrorSnack(context, 'approvals.reject', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,25 +174,54 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||||
/// drains as items succeed.
|
/// drains as items succeed.
|
||||||
Future<void> _batchApprove(List<ApprovalRecord> picked) async {
|
Future<void> _batchApprove(List<ApprovalRecord> picked) async {
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
|
// Same conscious-confirmation guard as the single approve, but for
|
||||||
|
// the whole batch: a one-click bulk approve must not silently
|
||||||
|
// sweep through the data-less requests the single path stops on.
|
||||||
|
final noData = picked.where((a) => a.payloadPreview == null).length;
|
||||||
|
if (noData > 0) {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(l.approvalsBatchNoDataTitle),
|
||||||
|
content: Text(l.approvalsBatchNoDataBody(noData, picked.length)),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: Text(l.buttonCancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: Text(l.approvalsNoDataConfirmAction),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
setState(() => _batchInFlight = true);
|
setState(() => _batchInFlight = true);
|
||||||
var ok = 0;
|
var ok = 0;
|
||||||
var failed = 0;
|
var failed = 0;
|
||||||
|
Object? firstError;
|
||||||
for (final a in picked) {
|
for (final a in picked) {
|
||||||
try {
|
try {
|
||||||
await HubService.instance.approve(a.id, _reviewer);
|
await HubService.instance.approve(a.id, _reviewer);
|
||||||
ok += 1;
|
ok += 1;
|
||||||
_selectedIds.remove(a.id);
|
_selectedIds.remove(a.id);
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
failed += 1;
|
failed += 1;
|
||||||
|
firstError ??= e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _batchInFlight = false);
|
setState(() => _batchInFlight = false);
|
||||||
_toast(
|
if (failed == 0) {
|
||||||
failed == 0
|
_toast(l.approvalsBatchApproveDoneToast(ok));
|
||||||
? l.approvalsBatchApproveDoneToast(ok)
|
} else {
|
||||||
: l.approvalsBatchPartialFailure(ok, failed),
|
// Partial failure: surface the copyable cause, not just a count.
|
||||||
);
|
// The failed items stay selected so they remain visible.
|
||||||
|
showChainErrorSnack(context, 'approvals.batchApprove', firstError!);
|
||||||
|
}
|
||||||
_refresh();
|
_refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -198,22 +232,24 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||||
setState(() => _batchInFlight = true);
|
setState(() => _batchInFlight = true);
|
||||||
var ok = 0;
|
var ok = 0;
|
||||||
var failed = 0;
|
var failed = 0;
|
||||||
|
Object? firstError;
|
||||||
for (final a in picked) {
|
for (final a in picked) {
|
||||||
try {
|
try {
|
||||||
await HubService.instance.reject(a.id, _reviewer, reason);
|
await HubService.instance.reject(a.id, _reviewer, reason);
|
||||||
ok += 1;
|
ok += 1;
|
||||||
_selectedIds.remove(a.id);
|
_selectedIds.remove(a.id);
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
failed += 1;
|
failed += 1;
|
||||||
|
firstError ??= e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _batchInFlight = false);
|
setState(() => _batchInFlight = false);
|
||||||
_toast(
|
if (failed == 0) {
|
||||||
failed == 0
|
_toast(l.approvalsBatchRejectDoneToast(ok));
|
||||||
? l.approvalsBatchRejectDoneToast(ok)
|
} else {
|
||||||
: l.approvalsBatchPartialFailure(ok, failed),
|
showChainErrorSnack(context, 'approvals.batchReject', firstError!);
|
||||||
);
|
}
|
||||||
_refresh();
|
_refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,26 +265,49 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: Text(l.approvalsRejectDialogTitle),
|
title: Text(l.approvalsRejectDialogTitle),
|
||||||
content: TextField(
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Explain the consequence at the point of input, and make
|
||||||
|
// the reason a real required field: the button stays
|
||||||
|
// disabled while it is empty instead of the dialog closing
|
||||||
|
// silently and nothing happening (usertest finding).
|
||||||
|
ChainInlineHelp(text: l.approvalsRejectDialogHelp),
|
||||||
|
const SizedBox(height: ChainSpace.md),
|
||||||
|
TextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
|
minLines: 1,
|
||||||
|
maxLines: 3,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: l.approvalsRejectReasonLabel,
|
labelText: l.approvalsRejectReasonLabel,
|
||||||
|
helperText: l.approvalsRejectReasonHelper,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx, null),
|
onPressed: () => Navigator.pop(ctx, null),
|
||||||
child: Text(l.buttonCancel),
|
child: Text(l.buttonCancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
ValueListenableBuilder<TextEditingValue>(
|
||||||
onPressed: () => Navigator.pop(ctx, controller.text),
|
valueListenable: controller,
|
||||||
|
builder: (ctx, value, _) {
|
||||||
|
final empty = value.text.trim().isEmpty;
|
||||||
|
return FilledButton(
|
||||||
|
onPressed: empty
|
||||||
|
? null
|
||||||
|
: () => Navigator.pop(ctx, controller.text.trim()),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||||||
foregroundColor: Theme.of(ctx).colorScheme.onError,
|
foregroundColor: Theme.of(ctx).colorScheme.onError,
|
||||||
),
|
),
|
||||||
child: Text(l.approvalsRejectButton),
|
child: Text(l.approvalsRejectButton),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -687,18 +746,22 @@ class _ApprovalCard extends StatelessWidget {
|
||||||
style: metaStyle,
|
style: metaStyle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (approval.createdAt != null)
|
||||||
originLine(
|
originLine(
|
||||||
Icons.schedule,
|
Icons.schedule,
|
||||||
Text(
|
Text(
|
||||||
'${l.approvalsOriginRequested}: '
|
'${l.approvalsOriginRequested}: '
|
||||||
'${_formatTimestamp(approval.createdAt.toLocal())}',
|
'${_formatTimestamp(approval.createdAt!.toLocal())}',
|
||||||
style: metaStyle,
|
style: metaStyle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (approval.flowExecution != null)
|
if (approval.flowExecution != null)
|
||||||
originLine(
|
originLine(
|
||||||
Icons.tag_outlined,
|
Icons.tag_outlined,
|
||||||
SelectableText(
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SelectableText(
|
||||||
'${l.approvalsOriginRun}: ${approval.flowExecution}',
|
'${l.approvalsOriginRun}: ${approval.flowExecution}',
|
||||||
style: ChainTheme.mono(
|
style: ChainTheme.mono(
|
||||||
size: 11,
|
size: 11,
|
||||||
|
|
@ -706,6 +769,37 @@ class _ApprovalCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// One-click copy so the run id can be pasted into the
|
||||||
|
// Audit page's search to reach the event timeline
|
||||||
|
// (a filtered deeplink is a larger shell change — see
|
||||||
|
// the backlog).
|
||||||
|
Tooltip(
|
||||||
|
message: l.approvalsCopyRun,
|
||||||
|
child: InkResponse(
|
||||||
|
radius: 16,
|
||||||
|
onTap: () async {
|
||||||
|
await Clipboard.setData(
|
||||||
|
ClipboardData(text: approval.flowExecution!),
|
||||||
|
);
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(l.approvalsRunCopied)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
child: Icon(
|
||||||
|
Icons.content_copy,
|
||||||
|
size: 13,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: ChainSpace.md),
|
const SizedBox(height: ChainSpace.md),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 4),
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
|
@ -733,7 +827,7 @@ class _ApprovalCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: SelectableText(
|
child: SelectableText(
|
||||||
_prettyPreview(approval.payloadPreview!),
|
_prettyJson(approval.payloadPreview!),
|
||||||
style: ChainTheme.mono(
|
style: ChainTheme.mono(
|
||||||
size: 11,
|
size: 11,
|
||||||
color: theme.colorScheme.onSurface,
|
color: theme.colorScheme.onSurface,
|
||||||
|
|
@ -813,16 +907,6 @@ class _ApprovalCard extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _prettyPreview(String raw) {
|
|
||||||
if (raw.isEmpty) return raw;
|
|
||||||
try {
|
|
||||||
final dynamic parsed = const JsonDecoder().convert(raw);
|
|
||||||
return const JsonEncoder.withIndent(' ').convert(parsed);
|
|
||||||
} catch (_) {
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _expiresInLabel(BuildContext context, DateTime t) {
|
String _expiresInLabel(BuildContext context, DateTime t) {
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
final remaining = t.difference(DateTime.now());
|
final remaining = t.difference(DateTime.now());
|
||||||
|
|
@ -955,12 +1039,13 @@ class _HistoryDialog extends StatelessWidget {
|
||||||
theme,
|
theme,
|
||||||
AppLocalizations.of(context)!.approvalsDialogDecided,
|
AppLocalizations.of(context)!.approvalsDialogDecided,
|
||||||
'${_formatTimestamp(record.decidedAt!.toLocal())} '
|
'${_formatTimestamp(record.decidedAt!.toLocal())} '
|
||||||
'· ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}',
|
'· ${record.decidedBy.isEmpty ? AppLocalizations.of(context)!.approvalsUnknownReviewer : record.decidedBy}',
|
||||||
),
|
),
|
||||||
|
if (record.createdAt != null)
|
||||||
_kv(
|
_kv(
|
||||||
theme,
|
theme,
|
||||||
AppLocalizations.of(context)!.approvalsDialogCreated,
|
AppLocalizations.of(context)!.approvalsDialogCreated,
|
||||||
_formatTimestamp(record.createdAt.toLocal()),
|
_formatTimestamp(record.createdAt!.toLocal()),
|
||||||
),
|
),
|
||||||
if (record.project.isNotEmpty)
|
if (record.project.isNotEmpty)
|
||||||
_kv(
|
_kv(
|
||||||
|
|
@ -1015,7 +1100,7 @@ class _HistoryDialog extends StatelessWidget {
|
||||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
child: SelectableText(
|
child: SelectableText(
|
||||||
record.payloadPreview!,
|
_prettyJson(record.payloadPreview!),
|
||||||
style: ChainTheme.mono(size: 11),
|
style: ChainTheme.mono(size: 11),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1064,6 +1149,19 @@ class _HistoryDialog extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pretty-print a JSON payload preview; returns the raw string
|
||||||
|
/// unchanged when it is empty or not valid JSON. Shared by the
|
||||||
|
/// pending card + the history dialog so both render identically.
|
||||||
|
String _prettyJson(String raw) {
|
||||||
|
if (raw.isEmpty) return raw;
|
||||||
|
try {
|
||||||
|
final dynamic parsed = const JsonDecoder().convert(raw);
|
||||||
|
return const JsonEncoder.withIndent(' ').convert(parsed);
|
||||||
|
} catch (_) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Locale-unambiguous YYYY-MM-DD HH:mm:ss formatter shared by
|
/// Locale-unambiguous YYYY-MM-DD HH:mm:ss formatter shared by
|
||||||
/// the history row + dialog. Always renders in the operator's
|
/// the history row + dialog. Always renders in the operator's
|
||||||
/// local time zone — ISO timestamps from the wire still appear
|
/// local time zone — ISO timestamps from the wire still appear
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@
|
||||||
// the Dart mapping layer; this pins that it reaches the card, next
|
// the Dart mapping layer; this pins that it reaches the card, next
|
||||||
// to the rest of the origin facts and the plain-language intro.
|
// to the rest of the origin facts and the plain-language intro.
|
||||||
//
|
//
|
||||||
|
// It also pins the honesty invariant from the usertest: a missing
|
||||||
|
// created_at must NOT be rendered as a fabricated "now" — the line
|
||||||
|
// is simply omitted, exactly like the run id on a legacy hub.
|
||||||
|
//
|
||||||
// Runs against the scriptable FakeHubService — never a real hub.
|
// Runs against the scriptable FakeHubService — never a real hub.
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
@ -31,33 +35,34 @@ String _allText(WidgetTester tester) {
|
||||||
return buf.toString();
|
return buf.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
ApprovalRecord _pending({DateTime? createdAt, String? runId = 'run-abc123'}) {
|
||||||
testWidgets('a pending approval card shows its full origin and an '
|
return ApprovalRecord(
|
||||||
'intro explaining approve / reject', (tester) async {
|
|
||||||
final fake = installFakeHub();
|
|
||||||
fake.approvals = [
|
|
||||||
ApprovalRecord(
|
|
||||||
id: 'apr-1',
|
id: 'apr-1',
|
||||||
flowName: 'classify-and-file',
|
flowName: 'classify-and-file',
|
||||||
stepId: 'review',
|
stepId: 'review',
|
||||||
prompt: 'Bitte die Klassifikation prüfen',
|
prompt: 'Bitte die Klassifikation prüfen',
|
||||||
payloadPreview: '{"label":"Rechnung"}',
|
payloadPreview: '{"label":"Rechnung"}',
|
||||||
createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0),
|
createdAt: createdAt,
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
decidedAt: null,
|
decidedAt: null,
|
||||||
decidedBy: '',
|
decidedBy: '',
|
||||||
reason: '',
|
reason: '',
|
||||||
project: 'lbs',
|
project: 'lbs',
|
||||||
flowExecution: 'run-abc123',
|
flowExecution: runId,
|
||||||
),
|
);
|
||||||
];
|
}
|
||||||
|
|
||||||
|
Future<String> _renderApprovals(
|
||||||
|
WidgetTester tester,
|
||||||
|
List<ApprovalRecord> approvals,
|
||||||
|
) async {
|
||||||
|
final fake = installFakeHub();
|
||||||
|
fake.approvals = approvals;
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
tester.view.physicalSize = const Size(1280, 900);
|
tester.view.physicalSize = const Size(1280, 900);
|
||||||
tester.view.devicePixelRatio = 1.0;
|
tester.view.devicePixelRatio = 1.0;
|
||||||
addTearDown(tester.view.reset);
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
const StudioApp(
|
const StudioApp(
|
||||||
initialThemeMode: ThemeModeValue.dark,
|
initialThemeMode: ThemeModeValue.dark,
|
||||||
|
|
@ -69,8 +74,18 @@ void main() {
|
||||||
for (var i = 0; i < 12; i++) {
|
for (var i = 0; i < 12; i++) {
|
||||||
await tester.pump(const Duration(milliseconds: 200));
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
}
|
}
|
||||||
|
|
||||||
final text = _allText(tester);
|
final text = _allText(tester);
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
await tester.pump(const Duration(minutes: 1));
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('a pending approval card shows its full origin and an '
|
||||||
|
'intro explaining approve / reject', (tester) async {
|
||||||
|
final text = await _renderApprovals(tester, [
|
||||||
|
_pending(createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0)),
|
||||||
|
]);
|
||||||
// The human question (headline).
|
// The human question (headline).
|
||||||
expect(text, contains('Bitte die Klassifikation prüfen'));
|
expect(text, contains('Bitte die Klassifikation prüfen'));
|
||||||
// The origin block — every fact a reviewer needs to place it.
|
// The origin block — every fact a reviewer needs to place it.
|
||||||
|
|
@ -82,9 +97,29 @@ void main() {
|
||||||
// The run id — the field that used to be dropped at the mapping.
|
// The run id — the field that used to be dropped at the mapping.
|
||||||
expect(text, contains('Lauf: run-abc123'));
|
expect(text, contains('Lauf: run-abc123'));
|
||||||
// The intro strip says what this inbox is and what the actions do.
|
// The intro strip says what this inbox is and what the actions do.
|
||||||
expect(text, contains('pausierte Flows'));
|
expect(text, contains('pausierte'));
|
||||||
|
});
|
||||||
|
|
||||||
await tester.pumpWidget(const SizedBox.shrink());
|
testWidgets('a missing requested-at time is omitted, never faked as '
|
||||||
await tester.pump(const Duration(minutes: 1));
|
'"now"', (tester) async {
|
||||||
|
// created_at unknown: the honest behaviour is to drop the line,
|
||||||
|
// not to invent a timestamp (which would even drift on refresh).
|
||||||
|
final text = await _renderApprovals(tester, [_pending(createdAt: null)]);
|
||||||
|
// The rest of the origin still renders.
|
||||||
|
expect(text, contains('HERKUNFT'));
|
||||||
|
expect(text, contains('Flow: classify-and-file'));
|
||||||
|
expect(text, contains('Lauf: run-abc123'));
|
||||||
|
// But no requested-at line at all.
|
||||||
|
expect(text, isNot(contains('Angefordert:')));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a legacy approval without a run id omits the run line', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final text = await _renderApprovals(tester, [
|
||||||
|
_pending(createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0), runId: null),
|
||||||
|
]);
|
||||||
|
expect(text, contains('HERKUNFT'));
|
||||||
|
expect(text, isNot(contains('Lauf:')));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue