Files
nextcloud-workflow-deck-aut…/src/PersonalSettings.vue
T
Patrick Niebeling 131ef2e938
Build package / package (push) Successful in 55s
Build package / php-lint (8.2) (push) Successful in 41s
Build package / php-lint (8.3) (push) Successful in 34s
Build package / php-lint (8.4) (push) Successful in 40s
Build package / xml-lint (push) Successful in 11s
Build package / unit-tests (push) Successful in 42s
Surface dead filters, and name board and stacks in the moved-card mail
A filter whose entries have all been deleted from the board was the one
failure mode of this app that was completely invisible: the workflow kept
running, kept updating last_run and matched nothing, forever. It happens
with deleted labels and just as easily when a filtered user loses board
access, because Deck's BoardService::deleteAcl() calls
assignedUsersMapper->deleteByParticipantOnBoard() and wipes that user's
card assignments.

Two low-stakes signals, no new column and no migration:

- WorkflowRunner::warnAboutDeadFilters() logs a warning on every run.
- workflowIssues in PersonalSettings.vue marks the row red.

Deliberately no mail and no `enabled = false`. One deleted label out of
three is harmless, and even a fully dead filter can be one board edit away
from being live again.

Both obey the rule the disable path already follows: only positive
knowledge. DeckIntegrationService::findFilterOptions() therefore throws
instead of degrading to [], unlike listLabels()/listParticipants(), where
an empty list only means an empty dropdown.

That also fixes an existing false positive of the same family:
loadStacksFor() stored [] when the request failed, so a single failed
fetch reported "Quell-Stapel und Ziel-Stapel nicht mehr vorhanden" for an
untouched board. Failed requests now store null and are read as "unknown".

The comparison itself lives in WorkflowRunner::findDeadFilters(), static
and side-effect free like cardMatchesFilters(), with unit tests -- notably
that an empty filter is never dead, which would otherwise flag every
unfiltered workflow.

Separately, the moved-card mail now names the board and both stacks.
DeckIntegrationService::describeTargets() resolves the titles at most once
per workflow run, and only when a card actually moved and the workflow
wants a mail; unreadable titles degrade to #<id> rather than costing the
user their notification.
2026-08-13 23:02:13 +02:00

502 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<NcSettingsSection
name="Deck Workflow-Automatisierung"
description="Verschiebt überfällige Karten aus einem Deck-Stapel automatisch in einen anderen Stapel. Ein Hintergrundjob prüft die Regeln alle paar Minuten.">
<NcNoteCard v-if="loadError" type="error">
{{ loadError }}
</NcNoteCard>
<div v-if="loading" class="wfda-loading">
<NcLoadingIcon :size="32" />
</div>
<template v-else>
<table v-if="workflows.length" class="wfda-table">
<thead>
<tr>
<th>Titel</th>
<th>Von Stapel</th>
<th>Nach Stapel</th>
<th>Filter</th>
<th>E-Mail</th>
<th>Aktiv</th>
<th />
</tr>
</thead>
<tbody>
<tr v-for="workflow in workflows" :key="workflow.id">
<td>
{{ workflow.title }}
<span v-if="workflowIssues[workflow.id]" class="wfda-issue">
{{ workflowIssues[workflow.id] }}
</span>
</td>
<td>{{ stackLabel(workflow.boardId, workflow.sourceStackId) }}</td>
<td>{{ stackLabel(workflow.boardId, workflow.targetStackId) }}</td>
<td>
<span v-if="!workflow.filterUserIds.length && !workflow.filterLabelIds.length">–</span>
<span v-else>
<span v-if="workflow.filterUserIds.length">{{ workflow.filterUserIds.length }} Benutzer</span>
<span v-if="workflow.filterUserIds.length && workflow.filterLabelIds.length">, </span>
<span v-if="workflow.filterLabelIds.length">{{ workflow.filterLabelIds.length }} Label</span>
</span>
</td>
<td>{{ workflow.notifyEmail ? 'Ja' : 'Nein' }}</td>
<td>{{ workflow.enabled ? 'Ja' : 'Nein' }}</td>
<td class="wfda-actions">
<NcButton variant="tertiary" @click="editWorkflow(workflow)">
Bearbeiten
</NcButton>
<NcButton variant="tertiary" @click="removeWorkflow(workflow)">
Löschen
</NcButton>
</td>
</tr>
</tbody>
</table>
<p v-else class="wfda-empty">
Noch keine Workflows angelegt.
</p>
<NcButton v-if="!showForm" variant="primary" class="wfda-add-button" @click="startCreate">
Workflow hinzufügen
</NcButton>
<form v-if="showForm" class="wfda-form" @submit.prevent="save">
<NcTextField
v-model="form.title"
label="Titel"
required />
<NcSelect
v-model="form.boardId"
input-label="Board"
:options="boardOptions"
:reduce="option => option.value"
placeholder="Board wählen"
@update:model-value="onBoardChange" />
<NcSelect
v-model="form.sourceStackId"
input-label="Quell-Stapel"
:options="sourceStackOptions"
:reduce="option => option.value"
:disabled="!form.boardId"
placeholder="Quell-Stapel wählen" />
<NcSelect
v-model="form.targetStackId"
input-label="Ziel-Stapel"
:options="targetStackOptions"
:reduce="option => option.value"
:disabled="!form.boardId"
placeholder="Ziel-Stapel wählen" />
<NcSelect
v-model="form.filterUserIds"
input-label="Nur für zugewiesene Benutzer (optional)"
:options="participantOptions"
:reduce="option => option.value"
:disabled="!form.boardId"
multiple
placeholder="Alle Benutzer" />
<NcSelect
v-model="form.filterLabelIds"
input-label="Nur mit Label (optional)"
:options="labelOptions"
:reduce="option => option.value"
:disabled="!form.boardId"
multiple
placeholder="Alle Labels" />
<NcCheckboxRadioSwitch v-model="form.notifyEmail">
Benachrichtigungs-E-Mail an mich senden
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch v-model="form.enabled">
Workflow aktiv
</NcCheckboxRadioSwitch>
<NcNoteCard v-if="formError" type="error">
{{ formError }}
</NcNoteCard>
<div class="wfda-form-actions">
<NcButton variant="primary" type="submit" :disabled="saving">
Speichern
</NcButton>
<NcButton variant="tertiary" type="button" :disabled="saving" @click="cancelForm">
Abbrechen
</NcButton>
</div>
</form>
</template>
</NcSettingsSection>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import {
createWorkflow,
deleteWorkflow,
fetchBoards,
fetchLabels,
fetchParticipants,
fetchStacks,
fetchWorkflows,
updateWorkflow,
} from './api.js'
const loading = ref(true)
const loadError = ref('')
const workflows = ref([])
const boards = ref([])
// Per board, and `null` whenever the request failed — see loadStacksFor().
const stacksByBoard = reactive({})
const labelsByBoard = reactive({})
const participantsByBoard = reactive({})
const showForm = ref(false)
const saving = ref(false)
const formError = ref('')
const editingId = ref(null)
const stackOptions = ref([])
const labelOptions = ref([])
const participantOptions = ref([])
// Which board the three lists above currently belong to.
const loadedBoardId = ref(null)
const boardOptions = computed(() => boards.value.map((board) => ({ value: board.id, label: board.title })))
// Source and target must differ, so each dropdown hides what the other one
// already holds — the error message on save stays as the backstop for
// workflows stored before this rule existed.
const sourceStackOptions = computed(() => stackOptions.value.filter((option) => option.value !== form.targetStackId))
const targetStackOptions = computed(() => stackOptions.value.filter((option) => option.value !== form.sourceStackId))
/**
* Workflows that stopped working, keyed by id. Two different kinds:
*
* - Board or stacks deleted in Deck. The background job switches these off
* by itself, but a row that just stopped working needs to say so instead
* of looking healthy.
* - Every entry of a filter deleted from the board. Nothing switches those
* off — deliberately, since one deleted label out of three is harmless —
* yet a filter that cannot match a single card any more is otherwise
* completely invisible: the workflow keeps running and moves nothing.
*/
const workflowIssues = computed(() => {
const issues = {}
for (const workflow of workflows.value) {
if (!boards.value.some((board) => board.id === workflow.boardId)) {
issues[workflow.id] = 'Board nicht mehr vorhanden'
continue
}
const stacks = stacksByBoard[workflow.boardId]
if (stacks) {
const missing = []
if (!stacks.some((stack) => stack.id === workflow.sourceStackId)) {
missing.push('Quell-Stapel')
}
if (!stacks.some((stack) => stack.id === workflow.targetStackId)) {
missing.push('Ziel-Stapel')
}
if (missing.length) {
issues[workflow.id] = `${missing.join(' und ')} nicht mehr vorhanden`
continue
}
}
const dead = []
if (isDeadFilter(workflow.filterLabelIds, labelsByBoard[workflow.boardId], 'id')) {
dead.push('Label-Filter')
}
if (isDeadFilter(workflow.filterUserIds, participantsByBoard[workflow.boardId], 'uid')) {
dead.push('Benutzer-Filter')
}
if (dead.length > 1) {
issues[workflow.id] = `${dead.join(' und ')} treffen keine Karte mehr`
} else if (dead.length) {
issues[workflow.id] = `${dead[0]} trifft keine Karte mehr`
}
}
return issues
})
/**
* True only on positive knowledge: the filter has entries, the board's list
* was actually loaded, and not one entry is still on it. A failed request
* (null) says nothing and must not turn a healthy row red.
*/
function isDeadFilter(selected, available, key) {
if (!selected.length || !available) {
return false
}
return !selected.some((value) => available.some((entry) => entry[key] === value))
}
const form = reactive(emptyForm())
function emptyForm() {
return {
title: '',
boardId: null,
sourceStackId: null,
targetStackId: null,
filterUserIds: [],
filterLabelIds: [],
notifyEmail: false,
enabled: true,
}
}
function stackLabel(boardId, stackId) {
const stacks = stacksByBoard[boardId]
if (!stacks) {
return `#${stackId}`
}
const stack = stacks.find((entry) => entry.id === stackId)
return stack ? stack.title : `#${stackId}`
}
async function loadStacksFor(boardId) {
if (!boardId || stacksByBoard[boardId]) {
return
}
// `null`, not `[]` — "could not ask Deck" has to stay distinguishable
// from "this board has none", or a single failed request reports every
// stack of the board as deleted.
stacksByBoard[boardId] = await fetchStacks(boardId).catch(() => null)
}
/**
* Labels and participants of a board — needed only to judge whether a stored
* filter can still match anything, so this is fetched just for the boards of
* workflows that actually use filters.
*/
async function loadFiltersFor(boardId) {
if (!boardId || labelsByBoard[boardId]) {
return
}
const [labels, participants] = await Promise.all([
fetchLabels(boardId).catch(() => null),
fetchParticipants(boardId).catch(() => null),
])
labelsByBoard[boardId] = labels
participantsByBoard[boardId] = participants
}
async function loadAll() {
loading.value = true
loadError.value = ''
try {
const [loadedWorkflows, loadedBoards] = await Promise.all([fetchWorkflows(), fetchBoards()])
workflows.value = loadedWorkflows
boards.value = loadedBoards
await Promise.all(loadedWorkflows.flatMap((workflow) => {
const pending = [loadStacksFor(workflow.boardId)]
if (workflow.filterLabelIds.length || workflow.filterUserIds.length) {
pending.push(loadFiltersFor(workflow.boardId))
}
return pending
}))
} catch (e) {
loadError.value = 'Konnte Workflows oder Deck-Boards nicht laden. Ist die Deck-App aktiviert?'
} finally {
loading.value = false
}
}
/**
* Fills the stack/label/user dropdowns for a board without touching the
* current selection — that separation matters: editing a workflow has to
* load the same options but must keep the stored stacks and filters.
*/
async function loadBoardOptions(boardId) {
stackOptions.value = []
labelOptions.value = []
participantOptions.value = []
loadedBoardId.value = boardId ?? null
if (!boardId) {
return
}
// One failing endpoint must not take the other two dropdowns down with it.
const [stacks, labels, participants] = await Promise.all([
fetchStacks(boardId).catch(() => null),
fetchLabels(boardId).catch(() => null),
fetchParticipants(boardId).catch(() => null),
])
// Feed the overview's caches too, so its red markers agree with what the
// form just loaded. `null` propagates on purpose: it means "unknown".
stacksByBoard[boardId] = stacks
labelsByBoard[boardId] = labels
participantsByBoard[boardId] = participants
stackOptions.value = (stacks ?? []).map((stack) => ({ value: stack.id, label: stack.title }))
labelOptions.value = (labels ?? []).map((label) => ({ value: label.id, label: label.title }))
participantOptions.value = (participants ?? []).map((participant) => ({ value: participant.uid, label: participant.displayName }))
}
/**
* Only for the board dropdown: a *different* board invalidates everything
* that referenced the old one. Re-picking the same board is a no-op, so it
* doesn't silently wipe the form.
*/
async function onBoardChange(boardId) {
if (boardId === loadedBoardId.value) {
return
}
form.sourceStackId = null
form.targetStackId = null
form.filterUserIds = []
form.filterLabelIds = []
await loadBoardOptions(boardId)
}
function startCreate() {
Object.assign(form, emptyForm())
editingId.value = null
formError.value = ''
loadBoardOptions(null)
showForm.value = true
}
async function editWorkflow(workflow) {
Object.assign(form, {
title: workflow.title,
boardId: workflow.boardId,
sourceStackId: workflow.sourceStackId,
targetStackId: workflow.targetStackId,
filterUserIds: [...workflow.filterUserIds],
filterLabelIds: [...workflow.filterLabelIds],
notifyEmail: workflow.notifyEmail,
enabled: workflow.enabled,
})
editingId.value = workflow.id
formError.value = ''
showForm.value = true
await loadBoardOptions(workflow.boardId)
}
function cancelForm() {
showForm.value = false
formError.value = ''
}
async function save() {
formError.value = ''
if (!form.title.trim()) {
formError.value = 'Bitte einen Titel angeben.'
return
}
if (!form.boardId || !form.sourceStackId || !form.targetStackId) {
formError.value = 'Bitte Board, Quell- und Ziel-Stapel wählen.'
return
}
if (form.sourceStackId === form.targetStackId) {
formError.value = 'Quell- und Ziel-Stapel müssen sich unterscheiden.'
return
}
saving.value = true
try {
if (editingId.value) {
await updateWorkflow(editingId.value, form)
} else {
await createWorkflow(form)
}
showForm.value = false
await loadAll()
} catch (e) {
formError.value = e?.response?.data?.ocs?.meta?.message || 'Speichern fehlgeschlagen.'
} finally {
saving.value = false
}
}
async function removeWorkflow(workflow) {
// eslint-disable-next-line no-alert
if (!window.confirm(`Workflow "${workflow.title}" wirklich löschen?`)) {
return
}
await deleteWorkflow(workflow.id)
await loadAll()
}
onMounted(loadAll)
</script>
<style scoped>
.wfda-loading {
display: flex;
justify-content: center;
padding: 20px;
}
.wfda-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 16px;
}
.wfda-table th,
.wfda-table td {
text-align: left;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border);
}
.wfda-actions {
display: flex;
gap: 4px;
justify-content: flex-end;
}
.wfda-issue {
display: block;
color: var(--color-error);
font-size: 0.9em;
}
.wfda-empty {
color: var(--color-text-maxcontrast);
margin-bottom: 16px;
}
.wfda-add-button {
margin-bottom: 16px;
}
.wfda-form {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 480px;
padding: 16px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large);
}
.wfda-form-actions {
display: flex;
gap: 8px;
}
</style>