Fix unusable personal settings form: v8 Vue idioms and Deck relations
Build package / package (push) Successful in 50s
Lint info.xml / xml-lint (push) Successful in 20s
Lint PHP / php-lint (8.2) (push) Successful in 58s
Lint PHP / php-lint (8.3) (push) Successful in 57s
Lint PHP / php-lint (8.4) (push) Successful in 55s
PHPUnit / unit-tests (push) Successful in 1m7s
Build package / package (push) Successful in 50s
Lint info.xml / xml-lint (push) Successful in 20s
Lint PHP / php-lint (8.2) (push) Successful in 58s
Lint PHP / php-lint (8.3) (push) Successful in 57s
Lint PHP / php-lint (8.4) (push) Successful in 55s
PHPUnit / unit-tests (push) Successful in 1m7s
The settings page rendered, but nothing in it worked: - All NcSelect dropdowns showed "undefined" for every option. In @nextcloud/vue 9 the `label` prop is vue-select's option display *key*, not a caption, so `label="Board"` read `option.Board`. Use `input-label`. - Saving always failed with "Bitte einen Titel angeben". Vue 3 dropped `.sync`; NcTextField and NcCheckboxRadioSwitch bind via `modelValue`, so `:value.sync` / `:checked.sync` never wrote back. Use `v-model`. - NcButton's style prop is now `variant`, `type` is the native button type and `native-type` is gone. `type="tertiary"` rendered `<button type="tertiary">`, which HTML falls back to `submit` for, making the cancel button submit the form. - The user dropdown was always empty. Deck's RelationalEntity replaces resolved relations with a RelationalObject once an entity is enriched, so `$acl->getParticipant()` returns that wrapper and the uid lives in `getPrimaryKey()` -- probing for `getUID()` yielded null. This also broke the background job's assigned-user filter, which shares the extractor. - A board's ACL never contains its owner (a private board has an empty ACL), so participants are now seeded with the owner, group ACL entries are expanded via IGroupManager and display names resolved via IUserManager. - One board appeared twice: getUserBoards() merges own/group/circle boards and includes archived and trashed ones. Deduplicate by id and drop those. Also keep one failing lookup in onBoardChange from taking the other two dropdowns down with it, and document all of the above in CLAUDE.md.
This commit is contained in:
@@ -90,6 +90,22 @@ None of these were discoverable locally — there's no npm here (see "Local-only
|
||||
- **`createAppConfig` prefixes every entry name with the app id**, so the entry `personal-settings` is emitted as `js/workflow_deck_automation-personal-settings.mjs` and `css/workflow_deck_automation-personal-settings.css`. `Util::addScript()`/`addStyle()` take that *full on-disk basename* (minus extension), not the bare entry name — `lib/Settings/Personal.php` therefore passes `Application::APP_ID . '-personal-settings'`. Getting this wrong produces a **silently blank settings page**: the template's empty mount `<div>` renders fine, and the only symptom is `Could not find resource workflow_deck_automation/js/….js to load` (`jsresourceloader`) plus a matching `Could not find resource file "/apps/workflow_deck_automation/css/….css"` in `nextcloud.log`. If you rename the entry in `vite.config.js`, rename it in `Personal.php` too.
|
||||
- **The bulk of the CSS lives in a hashed `*.chunk.css`**, loaded at runtime by the `.mjs` bundle (`cssCodeSplit: true`). The `css/workflow_deck_automation-personal-settings.css` that `addStyle()` points at is the ~100-byte stub produced by `createEmptyCSSEntryPoints: true`. A near-empty entry CSS file is expected — don't "fix" it by turning off code splitting.
|
||||
|
||||
## `@nextcloud/vue` 9 component API (Vue 3) — the v8 idioms silently do nothing
|
||||
|
||||
This app is on `@nextcloud/vue` ^9 / Vue 3. The v8 (Vue 2) prop spellings that most Nextcloud app code online still uses either fail silently or, worse, land as stray DOM attributes instead of erroring:
|
||||
|
||||
- **No `.sync`.** Vue 3 removed it. `NcTextField` and `NcCheckboxRadioSwitch` both bind through `modelValue`/`update:modelValue`, so it's `v-model="x"` — `:value.sync="x"` / `:checked.sync="x"` leave the bound state permanently at its initial value. That's how `form.title` stayed `''` and every save died on "Bitte einen Titel angeben."
|
||||
- **`NcSelect`'s `label` prop is the *option display key*, not a caption** (it's vue-select's). Passing `label="Board"` makes it read `option.Board` on every option and render `undefined` for all of them. The visible caption prop is `input-label`.
|
||||
- **`NcButton`: `variant` is the style, `type` is the native button type**, and `native-type` no longer exists (v8's `type="primary"` + `native-type="submit"` → v9's `variant="primary"` + `type="submit"`). A leftover `type="primary"` renders `<button type="primary">`, which HTML treats as the invalid-value default `submit` — so a *cancel* button silently submits the form.
|
||||
- `NcSelect`'s `reduce` is not declared on `NcSelect` itself; it reaches vue-select through attribute fallthrough (NcSelect's root element *is* the `VueSelect` component and it doesn't set `inheritAttrs: false`). It works, but it's fallthrough, not a documented prop.
|
||||
- `NcNoteCard`'s prop is still `type` (`success|info|warning|error`) — not everything was renamed to `variant`.
|
||||
|
||||
## Deck entities are "relational": getters don't return the uid string
|
||||
|
||||
Deck's `RelationalEntity` swaps resolved relations for an `OCA\Deck\Db\RelationalObject` once an entity is enriched. So on an enriched board/card, `$acl->getParticipant()`, `$board->getOwner()` and a card assignment's participant return a `RelationalObject` (wrapping `OCA\Deck\Db\User`/`Group`/`Circle`), **not** a uid — the uid is its `getPrimaryKey()`. Probing for `getUID()` on the returned object finds nothing and yields `null`, which is how the settings UI's user dropdown came up empty *and* why the runner's assigned-user filter could never match. `DeckIntegrationService::unwrapUid()` handles both shapes (bare string and `RelationalObject`); route any new participant/owner field through it.
|
||||
|
||||
Two more Deck facts that bit us in the same pass: a board's ACL does **not** contain its owner (a private board has an empty ACL, so participants must be seeded with `$board->getOwner()`), and ACL entries can be groups (`type === 1`) whose members have to be expanded via `IGroupManager`. `BoardService::getUserBoards()` merges own/group/circle boards and can return the same board twice, and it includes archived and trashed boards — `listBoardsForCurrentUser()` dedupes by id and drops those.
|
||||
|
||||
## Local-only notes
|
||||
|
||||
`CLAUDE.local.md` (gitignored) carries session-specific environment notes (e.g. "no PHP/npm available locally"). Check it at the start of work in this repo — it's not duplicated here since it can change independently of the committed guidance.
|
||||
|
||||
@@ -10,7 +10,9 @@ use OCA\Deck\Service\BoardService;
|
||||
use OCA\Deck\Service\CardService;
|
||||
use OCA\Deck\Service\StackService;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\IGroupManager;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserManager;
|
||||
use OCP\Server;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
@@ -28,8 +30,18 @@ use Throwable;
|
||||
class DeckIntegrationService {
|
||||
private const DECK_APP_ID = 'deck';
|
||||
|
||||
/**
|
||||
* Deck's Acl::PERMISSION_TYPE_* values, inlined so this class keeps
|
||||
* working (degrading to "treat it as a user") if Deck ever moves or
|
||||
* renames the constants.
|
||||
*/
|
||||
private const ACL_TYPE_USER = 0;
|
||||
private const ACL_TYPE_GROUP = 1;
|
||||
|
||||
public function __construct(
|
||||
private IAppManager $appManager,
|
||||
private IUserManager $userManager,
|
||||
private IGroupManager $groupManager,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
@@ -51,10 +63,30 @@ class DeckIntegrationService {
|
||||
return $this->resolve(BoardService::class)->getUserBoards();
|
||||
}, []);
|
||||
|
||||
return array_map(
|
||||
static fn ($board) => ['id' => $board->getId(), 'title' => $board->getTitle()],
|
||||
$boards,
|
||||
);
|
||||
// Deck merges own/group/circle boards, so the same board can come
|
||||
// back more than once; and boards in the trash or archived ones are
|
||||
// no useful automation target. Keyed by id => deduplicated.
|
||||
$result = [];
|
||||
foreach ($boards as $board) {
|
||||
$id = (int)$board->getId();
|
||||
if (isset($result[$id]) || $this->isBoardHidden($board)) {
|
||||
continue;
|
||||
}
|
||||
$title = $board->getTitle();
|
||||
$result[$id] = [
|
||||
'id' => $id,
|
||||
'title' => (is_string($title) && $title !== '') ? $title : ('#' . $id),
|
||||
];
|
||||
}
|
||||
|
||||
return array_values($result);
|
||||
}
|
||||
|
||||
private function isBoardHidden(mixed $board): bool {
|
||||
if (method_exists($board, 'getDeletedAt') && (int)$board->getDeletedAt() > 0) {
|
||||
return true;
|
||||
}
|
||||
return method_exists($board, 'getArchived') && $board->getArchived() === true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,25 +119,65 @@ class DeckIntegrationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Everyone who can hold a card on this board: the board owner (who is
|
||||
* *not* part of the ACL — a private board has an empty ACL) plus every
|
||||
* ACL entry, with group shares expanded to their members.
|
||||
*
|
||||
* @return array<int, array{uid: string, displayName: string}>
|
||||
*/
|
||||
public function listParticipants(int $boardId): array {
|
||||
$acl = $this->call(function () use ($boardId) {
|
||||
$uids = $this->call(function () use ($boardId) {
|
||||
$board = $this->resolve(BoardService::class)->find($boardId, true);
|
||||
return $board->getAcl() ?? [];
|
||||
|
||||
$uids = [];
|
||||
$owner = $this->unwrapUid($board->getOwner());
|
||||
if ($owner !== null) {
|
||||
$uids[] = $owner;
|
||||
}
|
||||
|
||||
foreach ($board->getAcl() ?? [] as $entry) {
|
||||
$principal = $this->extractParticipantUid($entry);
|
||||
if ($principal === null) {
|
||||
continue;
|
||||
}
|
||||
$type = method_exists($entry, 'getType') ? (int)$entry->getType() : self::ACL_TYPE_USER;
|
||||
if ($type === self::ACL_TYPE_GROUP) {
|
||||
array_push($uids, ...$this->groupMemberUids($principal));
|
||||
} elseif ($type === self::ACL_TYPE_USER) {
|
||||
$uids[] = $principal;
|
||||
}
|
||||
// Circles and federated shares are skipped: their members
|
||||
// cannot be resolved to plain uids here.
|
||||
}
|
||||
|
||||
return $uids;
|
||||
}, []);
|
||||
|
||||
$participants = [];
|
||||
foreach ($acl as $entry) {
|
||||
$uid = $this->extractParticipantUid($entry);
|
||||
if ($uid !== null && !isset($participants[$uid])) {
|
||||
$participants[$uid] = ['uid' => $uid, 'displayName' => $uid];
|
||||
foreach ($uids as $uid) {
|
||||
if (isset($participants[$uid])) {
|
||||
continue;
|
||||
}
|
||||
$participants[$uid] = [
|
||||
'uid' => $uid,
|
||||
'displayName' => $this->userManager->get($uid)?->getDisplayName() ?? $uid,
|
||||
];
|
||||
}
|
||||
|
||||
return array_values($participants);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function groupMemberUids(string $groupId): array {
|
||||
$group = $this->groupManager->get($groupId);
|
||||
if ($group === null) {
|
||||
return [];
|
||||
}
|
||||
return array_map(static fn (IUser $user) => $user->getUID(), $group->getUsers());
|
||||
}
|
||||
|
||||
/**
|
||||
* Cards in the given stack that are neither archived nor marked done,
|
||||
* enriched so getAssignedUsers()/getLabels() are populated.
|
||||
@@ -208,20 +280,57 @@ class DeckIntegrationService {
|
||||
* on one fixed method signature.
|
||||
*/
|
||||
private function extractParticipantUid(mixed $entry): ?string {
|
||||
if (is_string($entry)) {
|
||||
return $entry;
|
||||
$uid = $this->unwrapUid($entry);
|
||||
if ($uid !== null) {
|
||||
return $uid;
|
||||
}
|
||||
if (!is_object($entry)) {
|
||||
return null;
|
||||
}
|
||||
foreach (['getParticipant', 'getUid', 'getParticipantUid', 'getUserId'] as $method) {
|
||||
if (method_exists($entry, $method)) {
|
||||
$value = $entry->$method();
|
||||
if (is_string($value) && $value !== '') {
|
||||
return $value;
|
||||
$uid = $this->unwrapUid($entry->$method());
|
||||
if ($uid !== null) {
|
||||
return $uid;
|
||||
}
|
||||
if (is_object($value) && method_exists($value, 'getUID')) {
|
||||
return $value->getUID();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns whatever Deck hands out for a "participant"/"owner" field into
|
||||
* a plain uid.
|
||||
*
|
||||
* Enriched Deck entities do *not* return the raw uid string: their
|
||||
* RelationalEntity base swaps resolved relations for a
|
||||
* OCA\Deck\Db\RelationalObject wrapping a User/Group object, and its
|
||||
* primary key is the uid we want. Unenriched entities still return the
|
||||
* bare string, so both shapes are handled.
|
||||
*/
|
||||
private function unwrapUid(mixed $value): ?string {
|
||||
if (is_string($value)) {
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
if (!is_object($value)) {
|
||||
return null;
|
||||
}
|
||||
foreach (['getPrimaryKey', 'getUID', 'getUid', 'getId'] as $method) {
|
||||
if (method_exists($value, $method)) {
|
||||
$inner = $value->$method();
|
||||
if (is_string($inner) && $inner !== '') {
|
||||
return $inner;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (method_exists($value, 'getObject')) {
|
||||
$inner = $value->getObject();
|
||||
foreach (['getUID', 'getUid', 'getId'] as $method) {
|
||||
if (is_object($inner) && method_exists($inner, $method)) {
|
||||
$uid = $inner->$method();
|
||||
if (is_string($uid) && $uid !== '') {
|
||||
return $uid;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
-16
@@ -39,10 +39,10 @@
|
||||
<td>{{ workflow.notifyEmail ? 'Ja' : 'Nein' }}</td>
|
||||
<td>{{ workflow.enabled ? 'Ja' : 'Nein' }}</td>
|
||||
<td class="wfda-actions">
|
||||
<NcButton type="tertiary" @click="editWorkflow(workflow)">
|
||||
<NcButton variant="tertiary" @click="editWorkflow(workflow)">
|
||||
Bearbeiten
|
||||
</NcButton>
|
||||
<NcButton type="tertiary" @click="removeWorkflow(workflow)">
|
||||
<NcButton variant="tertiary" @click="removeWorkflow(workflow)">
|
||||
Löschen
|
||||
</NcButton>
|
||||
</td>
|
||||
@@ -53,19 +53,19 @@
|
||||
Noch keine Workflows angelegt.
|
||||
</p>
|
||||
|
||||
<NcButton v-if="!showForm" type="primary" class="wfda-add-button" @click="startCreate">
|
||||
<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
|
||||
:value.sync="form.title"
|
||||
v-model="form.title"
|
||||
label="Titel"
|
||||
required />
|
||||
|
||||
<NcSelect
|
||||
v-model="form.boardId"
|
||||
label="Board"
|
||||
input-label="Board"
|
||||
:options="boardOptions"
|
||||
:reduce="option => option.value"
|
||||
placeholder="Board wählen"
|
||||
@@ -73,7 +73,7 @@
|
||||
|
||||
<NcSelect
|
||||
v-model="form.sourceStackId"
|
||||
label="Quell-Stapel"
|
||||
input-label="Quell-Stapel"
|
||||
:options="stackOptions"
|
||||
:reduce="option => option.value"
|
||||
:disabled="!form.boardId"
|
||||
@@ -81,7 +81,7 @@
|
||||
|
||||
<NcSelect
|
||||
v-model="form.targetStackId"
|
||||
label="Ziel-Stapel"
|
||||
input-label="Ziel-Stapel"
|
||||
:options="stackOptions"
|
||||
:reduce="option => option.value"
|
||||
:disabled="!form.boardId"
|
||||
@@ -89,7 +89,7 @@
|
||||
|
||||
<NcSelect
|
||||
v-model="form.filterUserIds"
|
||||
label="Nur für zugewiesene Benutzer (optional)"
|
||||
input-label="Nur für zugewiesene Benutzer (optional)"
|
||||
:options="participantOptions"
|
||||
:reduce="option => option.value"
|
||||
:disabled="!form.boardId"
|
||||
@@ -98,18 +98,18 @@
|
||||
|
||||
<NcSelect
|
||||
v-model="form.filterLabelIds"
|
||||
label="Nur mit Label (optional)"
|
||||
input-label="Nur mit Label (optional)"
|
||||
:options="labelOptions"
|
||||
:reduce="option => option.value"
|
||||
:disabled="!form.boardId"
|
||||
multiple
|
||||
placeholder="Alle Labels" />
|
||||
|
||||
<NcCheckboxRadioSwitch :checked.sync="form.notifyEmail">
|
||||
<NcCheckboxRadioSwitch v-model="form.notifyEmail">
|
||||
Benachrichtigungs-E-Mail an mich senden
|
||||
</NcCheckboxRadioSwitch>
|
||||
|
||||
<NcCheckboxRadioSwitch :checked.sync="form.enabled">
|
||||
<NcCheckboxRadioSwitch v-model="form.enabled">
|
||||
Workflow aktiv
|
||||
</NcCheckboxRadioSwitch>
|
||||
|
||||
@@ -118,10 +118,10 @@
|
||||
</NcNoteCard>
|
||||
|
||||
<div class="wfda-form-actions">
|
||||
<NcButton type="primary" native-type="submit" :disabled="saving">
|
||||
<NcButton variant="primary" type="submit" :disabled="saving">
|
||||
Speichern
|
||||
</NcButton>
|
||||
<NcButton type="tertiary" :disabled="saving" @click="cancelForm">
|
||||
<NcButton variant="tertiary" type="button" :disabled="saving" @click="cancelForm">
|
||||
Abbrechen
|
||||
</NcButton>
|
||||
</div>
|
||||
@@ -230,10 +230,11 @@ async function onBoardChange(boardId) {
|
||||
return
|
||||
}
|
||||
|
||||
// One failing endpoint must not take the other two dropdowns down with it.
|
||||
const [stacks, labels, participants] = await Promise.all([
|
||||
fetchStacks(boardId),
|
||||
fetchLabels(boardId),
|
||||
fetchParticipants(boardId),
|
||||
fetchStacks(boardId).catch(() => []),
|
||||
fetchLabels(boardId).catch(() => []),
|
||||
fetchParticipants(boardId).catch(() => []),
|
||||
])
|
||||
|
||||
stacksByBoard[boardId] = stacks
|
||||
|
||||
Reference in New Issue
Block a user