mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
feat(plainspace): add integration for shared projects (#8424)
* docs(plainspace): add integration plan for shared projects
* feat(plainspace): prototype 'assigned to others' work-view panel
UI-only prototype of the dual-list shared-project view: a read-only
component that lists Plainspace tasks owned by other members, grouped by
assignee, rendered as a collapsible panel in the project work view.
Uses hard-coded sample data; foreign tasks never enter the SP task store
or op-log sync. See docs/plainspace-integration-plan.md.
* refactor(plainspace): apply review fixes to prototype panel
- use defined --text-color-muted / --s-half / --s2 tokens instead of an
undefined token and redundant spacing fallbacks
- drop unused PLAINSPACE.DONE i18n key
- note avatarUrl sanitization needed before live API data
- clarify plan doc: icon registration in GlobalThemeService, issueType
union widening, and which phases are design-only vs implemented
* feat(plainspace): add mock-backed issue provider + share-on-create toggle
Phase 1: register PLAINSPACE as an issue provider (config form, API
service with mock mode, IssueServiceInterface impl, issue.model/const/
service registration, icon, issue-content config). Tasks assigned to me or
unassigned import via the normal issue->backlog pipeline; tasks assigned to
others are excluded here (shown read-only by the separate panel).
Phase 3: add a 'Share on Plainspace' toggle to the create-project dialog
that provisions a (mock) space and a bound provider via
PlainspaceShareService.
All Plainspace calls are mocked (PLAINSPACE_USE_MOCK) so the flow works
without a live backend; the real HTTP contract is isolated in
PlainspaceApiService. Build + lint:ts + lint:scss pass; added a spec for
the provider's mock/filter logic (unit run blocked: no browser in env).
* refactor(plainspace): address review feedback on provider + share flow
- add a Plainspace tile to the issue-provider setup overview so the
provider is addable manually (connect to an existing space), not only
via the share-on-create toggle
- make PlainspaceShareService self-contained: catch errors, surface a
snack, never reject (safe to fire-and-forget); log ids only
- strip the transient isShareOnPlainspace flag before it can reach
sessionStorage on the dialog cancel path
- add a getIssueProviderTooltip case for PLAINSPACE (was falling through
to the raw key)
- drop a redundant cast now that PlainspaceIssue is in IssueDataReduced
* feat(plainspace): add account login / identity (Phase 2, mock)
- PlainspaceAccountService: signals (account/isLoggedIn/currentUserId),
mock login/logout, localStorage-persisted (local-only, never synced)
- 'mine' in the import filter now derives from the signed-in identity
instead of a hard-coded constant; not-logged-in => only unassigned
- the Share-on-Plainspace flow prompts (mock) sign-in if needed and
refuses to provision a space when the user declines
- move the mock user-id constant to the plainspace feature folder to
avoid a cross-folder import cycle
- specs for the account service and updated api-service spec (sign in
before asserting the mine/unassigned split)
Identity is mocked (fixed user id) so the assigned/unassigned split lines
up with the mock space data; real OAuth/token exchange is future work.
* feat(plainspace): feed 'assigned to others' panel from live mock data
Wire the work-view panel to a new PlainspaceSharedTasksService instead of
hard-coded sample data: for a project with a bound enabled PLAINSPACE
provider, it fetches the space's tasks, keeps only those assigned to
others (assigneeId !== me), maps them to read-only rows, and threads them
through project-task-page -> work-view as an input. The panel now appears
only for shared projects (not every project), and foreign tasks still
never enter the SP task store / op-log.
Removes the prototype data const; adds a service spec.
* refactor(plainspace): replace 'assigned to others' list with a claim pool
Reframe around task ownership: SP shows only tasks assigned to me (first-
class) plus a read-only 'claim pool' of unclaimed tasks. Tasks assigned to
others are no longer represented in SP.
- import filter is now assigned-to-me only (was mine+unassigned)
- new claim flow: PlainspaceApiService.claimTask$ (assign-to-self) +
PlainspaceClaimPoolService.claim -> IssueService.addTaskFromIssue, with
pool refresh; claimed task leaves the pool
- rename AssignedToOthers component/service -> claim-pool; panel is
collapsed by default (a pool you reach for, not active work)
- drop assignee badges (redundant: list is all 'mine'; provider icon
already marks Plainspace tasks)
- mock data made resettable for test isolation; specs updated/added
- docs: capture the ownership model + rationale for dropping the list
* docs(plainspace): fix remaining stale claim-pool reference
* feat(plainspace): connect provider to the real integration API
Replace the mock-backed PlainspaceApiService with the real PAT-authed
{host}/api/integration endpoints (me, tasks, claimable-tasks, claim,
spaces, done PATCH) and map the wire SPTask -> the internal PlainspaceIssue
so the rest of the provider depends on one stable shape.
- the server scopes /tasks to the caller, so drop client-side identity and
the mock data/identity consts; "mine" is decided server-side
- store the personal API token (PAT) in PlainspaceCfg like other providers'
secrets; keep a local-only account cache to bootstrap share-on-create
- add a token field to the config form; the share flow prompts for and
validates a PAT via /me before provisioning a space
- isEnabled now requires a token; testConnection hits /me; issueLink uses
the task's real url
Adds docs/plainspace-api-extension-plan.md documenting the server contract
this client consumes (the endpoints added to Johannesjo/spaces).
* feat(plainspace): guided connect dialog with token steps + link
Replace the bare one-line API-token prompt with PlainspaceConnectDialog,
which shows where to create a personal API token and validates it before
closing:
- numbered step-by-step (Space -> People -> Advanced -> API tokens) plus an
"Open Plainspace" link button
- inline validation via /me; an invalid token shows an error and keeps the
dialog open instead of failing silently
- wire it into the share-on-create flow and sharpen the config-form token
description to point at the same place
Adds the CONNECT i18n block (regenerates t.const) and drops the now-unused
LOGIN_PROMPT string; a new component spec covers connect/cancel + template.
* feat(plainspace): auto-import + two-way done sync
- default isAutoAddToBacklog to true so tasks assigned to me auto-import
into the bound project's backlog (poll-to-backlog), matching the seamless
intent
- add PlainspaceSyncAdapterService, registered for 'PLAINSPACE' in the
two-way-sync effect, so completing/reopening a task in SP pushes the done
state back to Plainspace via PATCH /tasks/:id { done }
Only isDone is pushed (the integration API's one writable field); title and
done changes from Plainspace are pulled by the existing update polling. Adds
an adapter spec and a Plainspace adapter spy in the two-way-sync effect spec.
* fix(plainspace): match bound space by slug or id so tasks import
getMyTasks$ filtered by `task.projectId === cfg.spaceId`, but the value users
copy from the space URL is the slug while SPTask.projectId is the UUID — so a
slug-configured provider matched zero tasks and imported nothing, even though
the /tasks response was full.
Match spaceId against both projectId and projectSlug (getMyTasks$ and the claim
pool), drop the server ?projectId= param (UUID-only) in favour of the same
client-side match, and point the Space ID help text at the slug in the URL.
* chore(plainspace): log import counts to diagnose missing imports
getMyTasks$ now logs total vs space-matched task counts (ids/counts only,
no task content) so we can tell whether the space filter or the import is
dropping tasks. Temporary diagnostic.
* feat: add picker for plainspace
* feat: improve wording
* feat(plainspace): use two-circle brand mark for provider icon
* refactor(plainspace): type create-project form to drop isShareOnPlainspace any-cast
* refactor(plainspace): remove temporary getMyTasks$ diagnostic log
* feat(plainspace): push scheduled time (dueWithTime → remindAt)
* fix(plainspace): seed two-way-sync baseline so write-back actually fires
* feat(plainspace): import remindAt as dueWithTime so schedule shows in app
* refactor(plainspace): align client to scheduledAt/isRecurring API contract
* feat(plainspace): flag recurring tasks in the claim pool
* feat(plainspace): pull scheduledAt into dueWithTime on poll (schedule existing + recurring tasks)
* feat(plainspace): push title changes back to Plainspace
* fix(plainspace): address multi-review findings (perf, UX, cleanup)
Performance:
- claim pool no longer re-fetches /claimable-tasks on every task
add/complete/reorder (distinct on project id + provider identity)
- poll imported tasks via one getMyTasks$ instead of N getById$ calls
UX:
- gate the "Share on Plainspace" toggle to project create (was a dead
control in edit mode)
- success/failure snacks on claim and share (silent 409/offline before)
- distinct error state in the space-picker (vs misleading "no spaces yet")
Correctness/cleanup:
- normalize scheduledAt to canonical UTC ISO on read so the push guard
can't silently drop a reschedule on a benign reformat
- drop dead PLAINSPACE_INITIAL_POLL_DELAY, PlainspaceMember, assignee
- translate the "Advanced Config" form label
* docs(plainspace): reconcile token storage + forward-compat rollout note
- document that the PAT lives in synced provider cfg like other providers
(the earlier "not stored here" note was never true in the shipped code)
- add the typia forward-compat rollout requirement for the new built-in
PLAINSPACE issue-provider key to the Risks section
- fix stale "assigned to others" wording (now the claim pool)
* feat(plainspace): trim redundant Notes button + issue panel
Plainspace mirrors title/done/schedule onto native task fields, so the
detail panel only echoed the task and the chat button opened an
essentially empty drawer.
- Treat PLAINSPACE like ICAL: don't show the *persistent* toggle button
just because the task carries an issueId. Keep it for real notes, a
remote update (issueWasUpdated) and the open-panel close state.
- Keep the panel reachable: the hover-only "open detail panel" button
(task-hover-controls) now covers PLAINSPACE too — it's the exact
complement of the persistent button, so plain/iCal/Plainspace tasks all
get a hover affordance and there's never a double button.
- Replace the redundant title-as-"Summary" link with the two things not
already on the task: a clear "Open in Plainspace" link and a recurrence
indicator rendered with the same `repeat` mat-icon the claim pool uses
(new 'plainspace-recurring' custom field).
- Add unit coverage for the new content config.
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
0150ee23b3
commit
91d8dd80ba
57 changed files with 3690 additions and 54 deletions
462
docs/plainspace-api-extension-plan.md
Normal file
462
docs/plainspace-api-extension-plan.md
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
# Plainspace API extension plan — to enable the Super Productivity integration
|
||||
|
||||
**Audience:** the Plainspace server team (`Johannesjo/spaces`, `packages/server`).
|
||||
**Author:** drafted from the SP side while wiring PR #8424 (SP ↔ Plainspace).
|
||||
**Status:** proposal — three small PAT-authed endpoints + one optional polling
|
||||
enhancement. No breaking changes; all additive.
|
||||
|
||||
> **Why this exists.** SP's Plainspace integration (PR #8424) was built against an
|
||||
> _assumed_ API and is currently mock-backed. Connecting it to the real backend
|
||||
> revealed that the live `/api/integration` surface supports only **read my
|
||||
> assigned tasks** and **toggle done** — which covers SP's "import my tasks +
|
||||
> sync completion" path, but **not** the two features in the PR's final commits:
|
||||
> the **claim pool** (claim an unassigned task) and **Share on Plainspace**
|
||||
> (create a space from SP). Those need new server endpoints. This document
|
||||
> specifies them, grounded in the existing code so they drop into the current
|
||||
> patterns.
|
||||
|
||||
---
|
||||
|
||||
## 1. What the SP client needs, and what exists today
|
||||
|
||||
| SP feature | SP API call (client) | Real endpoint today | Gap |
|
||||
| -------------------------------------- | ------------------------------------------------------ | ---------------------------------- | -------------------------------------------------- |
|
||||
| Verify token / identity | `getMe$` | `GET /api/integration/me` | ✅ exists |
|
||||
| Import my assigned tasks | `getMyTasks$` | `GET /api/integration/tasks` | ✅ exists (filter client-side by space) |
|
||||
| Refresh one task | `getById$` | `GET /api/integration/tasks/:id` | ✅ exists |
|
||||
| Push completion back | done write-back | `PATCH /api/integration/tasks/:id` | ✅ exists |
|
||||
| **Push scheduled time** | `patchTask$ { scheduledAt }` | `PATCH /api/integration/tasks/:id` | ⚠️ **extend** (accept `scheduledAt`) |
|
||||
| **Read scheduled time** | `getMyTasks$`/`getById$` → `scheduledAt`/`isRecurring` | `serializeSPTask` | ⚠️ **extend** (expose `scheduledAt`/`isRecurring`) |
|
||||
| **Claim pool (list unclaimed)** | `getUnclaimedTasks$` | — | ❌ **new endpoint** |
|
||||
| **Claim a task (self-assign)** | `claimTask$` | — | ❌ **new endpoint** |
|
||||
| **Share on Plainspace (create space)** | `createSpace$` | — | ❌ **new endpoint** |
|
||||
| Efficient polling | poll loop | full refetch only | ⚠️ optional: `?updatedSince=` |
|
||||
|
||||
All three new endpoints reuse the existing `apiTokenMiddleware`, the
|
||||
`loadIntegrationScope()` helper, and the `SPTask` DTO already defined in
|
||||
`integration.ts`. No new auth machinery.
|
||||
|
||||
### Recap of the existing integration model (for context)
|
||||
|
||||
`apiTokenMiddleware` resolves a `pat_…` bearer token to a verified email
|
||||
(blind-indexed `emailLookup`). `loadIntegrationScope(emailLookup)` then finds
|
||||
every `members` row for that email where `emailVerified = true` and
|
||||
`tosVersion = TOS_VERSION`, yielding the set of projects the token may act in.
|
||||
A single PAT therefore spans **all** of the caller's Spaces — keep that property
|
||||
for the new endpoints.
|
||||
|
||||
---
|
||||
|
||||
## 2. New endpoint 1 — Claim a task (self-assign)
|
||||
|
||||
```
|
||||
POST /api/integration/tasks/:taskId/claim
|
||||
Authorization: Bearer pat_…
|
||||
→ 200 { task: SPTask }
|
||||
```
|
||||
|
||||
Assigns an **unassigned**, not-deleted task to the caller's member in that task's
|
||||
project — the server-side of SP's "Claim" button. Mirror the assignment path in
|
||||
`routes/items.ts` (the `item.assigned` branch) and the transactional pattern in
|
||||
the existing `PATCH /api/integration/tasks/:taskId`.
|
||||
|
||||
**Logic**
|
||||
|
||||
1. Load scope. If empty → `404 { error: 'Task not found' }`.
|
||||
2. Look up the item by id, restricted to `scope.projectIds`, `isNull(deletedAt)`.
|
||||
Not found → `404`.
|
||||
3. Resolve the caller's member for that project:
|
||||
`member = scope.memberByProjectId.get(item.projectId)`. Missing → `404`.
|
||||
4. **Atomic claim** (handles the race where two members claim at once — same
|
||||
conditional-update pattern as the verification-code claim in `projects.ts`):
|
||||
|
||||
```ts
|
||||
const [claimed] = await tx
|
||||
.update(items)
|
||||
.set({ assignedTo: member.id })
|
||||
.where(
|
||||
and(
|
||||
eq(items.id, taskId),
|
||||
eq(items.projectId, item.projectId),
|
||||
isNull(items.assignedTo), // only if still unclaimed
|
||||
isNull(items.deletedAt),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (!claimed) return c.json({ error: 'Task already claimed' }, 409); // lost the race / already assigned
|
||||
```
|
||||
|
||||
5. `recordActivity(tx, { action: 'item.assigned', targetType: 'item',
|
||||
targetId: taskId, memberId: member.id, meta: { text: item.text,
|
||||
assignedTo: member.id, source: 'sp' } })`.
|
||||
6. After commit: `sseManager.broadcast(projectId, 'item.updated',
|
||||
{ item: serializeItem(claimed), memberId: member.id })` and the `activity`
|
||||
broadcast — exactly like the PATCH handler, so open web clients see the claim
|
||||
live.
|
||||
7. **Do not** enqueue an `assignmentNotifications` row: claiming is a
|
||||
self-assignment, and that table already excludes self-assignments by design
|
||||
(see the comment on `assignmentNotifications` and the `assignee !== member.id`
|
||||
guard in `items.ts`). Pinging yourself about a task you just claimed is noise.
|
||||
8. Return `{ task: serializeSPTask(claimed, list, proj, origin) }` (fetch `list`
|
||||
- `proj` as the PATCH handler does, or inside the tx).
|
||||
|
||||
**Status codes:** `200` claimed · `409` already assigned (to anyone, incl. you) ·
|
||||
`404` unknown task / not a member / deleted · `401` bad token.
|
||||
|
||||
> **Design note — why claim is its own endpoint, not `PATCH …{ assignedTo }`.**
|
||||
> Exposing arbitrary `assignedTo` over a PAT would let SP assign tasks to _other_
|
||||
> members, which the integration's security model deliberately forbids (a PAT
|
||||
> acts only as its own member). `claim` is the one safe, self-scoped assignment:
|
||||
> it can only ever set `assignedTo = me`, and only from unassigned. This matches
|
||||
> the asymmetry in `docs/plans/2026-06-02-super-productivity-integration.md`
|
||||
> ("never let SP dictate assignment into a third party").
|
||||
|
||||
---
|
||||
|
||||
## 3. New endpoint 2 — List claimable (unassigned) tasks
|
||||
|
||||
```
|
||||
GET /api/integration/claimable-tasks → { tasks: SPTask[] }
|
||||
GET /api/integration/claimable-tasks?projectId=<uuid> (optional filter)
|
||||
```
|
||||
|
||||
The read side of the claim pool: unassigned, not-done, not-deleted items in the
|
||||
projects the caller is a member of. Same `SPTask` DTO as `/tasks`, so SP's mapper
|
||||
is unchanged. Structurally identical to the existing `GET /tasks`, with the
|
||||
assignment predicate inverted:
|
||||
|
||||
```ts
|
||||
const scope = await loadIntegrationScope(emailLookup);
|
||||
if (scope.memberRows.length === 0) return c.json({ tasks: [] });
|
||||
|
||||
const rows = await db.query.items.findMany({
|
||||
where: and(
|
||||
inArray(items.projectId, projectIdFilter ?? scope.projectIds),
|
||||
isNull(items.assignedTo), // unclaimed
|
||||
eq(items.checked, false), // not done
|
||||
isNull(items.deletedAt),
|
||||
),
|
||||
});
|
||||
// then the same list/project hydration + serializeSPTask loop as GET /tasks
|
||||
```
|
||||
|
||||
- `?projectId=` (when present) **must** be intersected with `scope.projectIds`
|
||||
so it can't be used to probe foreign projects. SP passes its bound
|
||||
`spaceId` here to avoid over-fetching unclaimed tasks from unrelated Spaces.
|
||||
- Visibility is membership-based and independent of `sharingMode`: members of a
|
||||
`private` Space still see its unclaimed items (sharingMode gates _joining_,
|
||||
not member visibility).
|
||||
|
||||
> **Cheaper alternative if you'd rather not add a path:** a query param on the
|
||||
> existing endpoint — `GET /api/integration/tasks?scope=claimable` (default
|
||||
> `scope=assigned`). The dedicated path keeps `/tasks` semantics crisp and reads
|
||||
> better client-side; either is fine. (This is also the natural place the
|
||||
> `?updatedSince=` param from §6 would live.)
|
||||
|
||||
---
|
||||
|
||||
## 4. New endpoint 3 — Create a space (Share on Plainspace)
|
||||
|
||||
```
|
||||
POST /api/integration/spaces
|
||||
Authorization: Bearer pat_…
|
||||
Body: { name: string, purpose?: string, displayName?: string }
|
||||
→ 201 { project: { id, slug, name, purpose, sharingMode }, memberId }
|
||||
```
|
||||
|
||||
Provisions a new Space owned by the PAT's email and returns the ids SP binds its
|
||||
provider to (`PlainspaceCfg.spaceId = project.id`, link via `project.url`).
|
||||
Mirror the `POST /api/projects` transaction in `routes/projects.ts`, minus the
|
||||
email-code gate.
|
||||
|
||||
**Logic**
|
||||
|
||||
1. Decrypt the PAT's email: `decryptStoredEmail(row)` is already done in
|
||||
`apiTokenMiddleware`; expose it via `c.get('apiTokenEmail')` (the middleware
|
||||
sets it). Normalize with `normalizeEmail`.
|
||||
2. Validate body with a new `CreateSpaceViaTokenSchema` (zod, in
|
||||
`packages/shared/src/validation.ts`):
|
||||
`name 1..MAX_PROJECT_NAME_LENGTH`, `purpose ≤ MAX_PURPOSE_LENGTH default ''`,
|
||||
`displayName 1..MAX_DISPLAY_NAME_LENGTH` (default to the email local-part if
|
||||
omitted). `safeParse` failure → `422` with `details: error.flatten()`.
|
||||
3. Transaction (mirrors `projects.ts`):
|
||||
```ts
|
||||
const slug = nanoid(SLUG_LENGTH);
|
||||
const [project] = await tx
|
||||
.insert(projects)
|
||||
.values({ slug, name, purpose })
|
||||
.returning();
|
||||
const [member] = await tx
|
||||
.insert(members)
|
||||
.values({
|
||||
projectId: project.id,
|
||||
tokenHash: hashToken(nanoid(TOKEN_LENGTH)), // web-session token; unused by SP, see note
|
||||
displayName,
|
||||
...encryptedEmailFields(memberEmail),
|
||||
emailVerified: true, // the PAT already proves email ownership
|
||||
color: MEMBER_COLORS[0],
|
||||
avatarIndex: 0,
|
||||
isCreator: true,
|
||||
role: 'admin',
|
||||
tosVersion: TOS_VERSION,
|
||||
tosAcceptedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
await ensureProjectDefaults(tx, { projectId: project.id, memberId: member.id });
|
||||
```
|
||||
4. Return `201 { project: serializeProject(project), memberId: member.id }`.
|
||||
|
||||
**Why no email-verification code here.** `POST /api/projects` gates creation on a
|
||||
6-digit emailed code _or_ a `proofToken` from an existing Space (see
|
||||
`resolveProofEmail` / `proofVerified`). A valid PAT is a strictly stronger proof
|
||||
of the same email ownership — it was minted (`api-tokens.ts`) only after that
|
||||
email was verified inside a Space. So `emailVerified: true` and skipping the code
|
||||
is consistent with the existing `proofToken` shortcut, not a new trust
|
||||
assumption.
|
||||
|
||||
**The new member's token.** SP does **not** need the returned web-session
|
||||
`token`: because the new member shares the PAT's `emailLookup`, is
|
||||
`emailVerified`, and carries the current `tosVersion`, `loadIntegrationScope`
|
||||
**immediately** includes the new Space for the same PAT. So the existing PAT can
|
||||
read/claim/patch in the new Space with no re-auth. Returning `memberId` is enough;
|
||||
omit the session token (or return it for parity — your call).
|
||||
|
||||
**Rate limiting.** `POST /api/projects` is IP-rate-limited + code-gated. This
|
||||
endpoint has neither, so add a per-email cap to stop a leaked PAT from mass-
|
||||
creating Spaces — e.g. `checkRateLimit('create-space-token:' + apiTokenId, N,
|
||||
window)` (reuse `lib/rate-limit.ts`). Suggest something conservative (e.g. 10 /
|
||||
hour).
|
||||
|
||||
**Status codes:** `201` created · `422` validation · `429` rate-limited ·
|
||||
`401` bad token.
|
||||
|
||||
---
|
||||
|
||||
## 4b. Scheduled time — expose `scheduledAt` / `isRecurring`, accept `scheduledAt`
|
||||
|
||||
SP syncs a task's **scheduled time** (`task.dueWithTime`) to a Plainspace item's
|
||||
existing `remindAt` column. This is **not a new endpoint** — it extends the
|
||||
`SPTask` DTO (read) and the existing `PATCH /tasks/:id` (write). No new tables;
|
||||
`items.remindAt` + `items.repeat` and the whole reminder/repeat machinery already
|
||||
exist. The DTO uses **SP-facing names** that map to those columns:
|
||||
|
||||
| DTO field (`SPTask`) | DB column | Meaning |
|
||||
| ----------------------------- | ---------------------- | ---------------------------------------------- |
|
||||
| `scheduledAt: string \| null` | `items.remindAt` | ISO instant the task is scheduled for, or null |
|
||||
| `isRecurring: boolean` | `items.repeat != null` | whether it repeats (cadence stays server-side) |
|
||||
|
||||
### Read — add `scheduledAt` + `isRecurring` to `serializeSPTask`
|
||||
|
||||
```ts
|
||||
return {
|
||||
// …existing fields…
|
||||
scheduledAt: item.remindAt ? item.remindAt.toISOString() : null,
|
||||
isRecurring: item.repeat != null,
|
||||
};
|
||||
```
|
||||
|
||||
So `getMyTasks$`/`getById$`/`claimable-tasks` all carry them. `isRecurring` is the
|
||||
yes/no flag SP needs to surface recurrence; the rule itself never crosses the wire.
|
||||
|
||||
### Write — accept `scheduledAt` on `PATCH /tasks/:id`
|
||||
|
||||
Today the integration PATCH only accepts `{ done: boolean }`. Widen its body
|
||||
schema to also accept `scheduledAt` (mapped to the `remindAt` column):
|
||||
|
||||
```
|
||||
PATCH /api/integration/tasks/:taskId
|
||||
Body: { done?: boolean, scheduledAt?: string | null } // ISO instant, or null to unschedule
|
||||
```
|
||||
|
||||
- Apply the **same `remindAt`/`repeat`/`anchor` invariants** the in-app PATCH uses
|
||||
(`applyRepeatUpdate` in `items.ts`): clearing `scheduledAt` (→ `remindAt = null`)
|
||||
cascades to `repeat:null`; re-scheduling a repeating item re-anchors the rule.
|
||||
SP never sends a rule, so a `scheduledAt`-only PATCH on a repeating item hits
|
||||
exactly the "re-anchor existing rule" branch.
|
||||
- Still **member-scoped**: the PAT can only patch items in `scope.projectIds`,
|
||||
and only ever its own caller's item — same guard as the done write-back. SP
|
||||
setting `scheduledAt` is self-scoped scheduling, not assignment.
|
||||
- Validation: reject a `scheduledAt` that isn't a valid ISO instant or `null`
|
||||
(`422`).
|
||||
|
||||
### Recurrence — server stays authoritative, SP just tracks `scheduledAt`
|
||||
|
||||
Deliberately **no rule translation** between Plainspace `RepeatRule` and SP's
|
||||
`TaskRepeatCfg` (different execution models — Plainspace = one persistent row the
|
||||
sweep advances; SP = a template that spawns instances — and SP's recurrence is
|
||||
mid-refactor). Instead:
|
||||
|
||||
- **Plainspace → SP:** a repeating item imports as a single ordinary SP task with
|
||||
`dueWithTime = scheduledAt` (the next occurrence); `isRecurring` flags it. When
|
||||
the sweep advances `remindAt`, SP's poll re-pulls the new `scheduledAt` and
|
||||
reschedules the same task. SP needs zero knowledge of the rule.
|
||||
- **SP → Plainspace:** SP only ever PATCHes a concrete `scheduledAt` (never a
|
||||
rule). An SP-recurring task pushes each occurrence as a one-off; Plainspace
|
||||
keeps owning any rule it created.
|
||||
|
||||
> Behavior to expect (not a bug): completing an imported **recurring** task in SP
|
||||
> write-backs `done`, the sweep then advances + un-checks the item, and SP's next
|
||||
> poll reopens it at the new time. Correct for a recurring item; the
|
||||
> done-write-back ↔ scheduledAt-re-pull interaction wants an idempotency test.
|
||||
|
||||
### Client scope in PR #8424
|
||||
|
||||
The SP side imports `scheduledAt → dueWithTime` on task add (schedule shows in the
|
||||
app, with `isRecurring` available to flag recurrence) and pushes
|
||||
`dueWithTime → scheduledAt` (incl. `null` on unschedule), mirroring the done
|
||||
write-back. The recurrence-tracking poll extension (re-pull an advanced
|
||||
`scheduledAt`) is a documented follow-up. `dueDay` (date-only SP scheduling) is
|
||||
intentionally **not** synced — `scheduledAt` always carries a time, so mapping a
|
||||
day-only task would fabricate one.
|
||||
|
||||
---
|
||||
|
||||
## 5. Onboarding caveat (important for the SP "Share" UX)
|
||||
|
||||
A PAT can only be minted from **inside an existing Space**
|
||||
(`POST /api/projects/:slug/auth/api-tokens` requires a logged-in, email-verified
|
||||
member). Therefore:
|
||||
|
||||
- **Creating an _additional_ Space from SP** (the user already has a PAT) →
|
||||
fully covered by §4. ✅
|
||||
- **Creating a user's _first_ Space from SP** (no Space, no PAT yet) → **not**
|
||||
possible with PAT-only, because there's nothing to mint a PAT from. This is the
|
||||
chicken-and-egg the **device-code flow** in
|
||||
`docs/plans/2026-06-02-super-productivity-integration.md` ("Auth: device-code,
|
||||
not copy-paste") is meant to solve. Until that lands, SP should gate "Share on
|
||||
Plainspace" behind "paste a PAT" (i.e. the user is already a Plainspace member)
|
||||
and word the empty state accordingly.
|
||||
|
||||
Recommend scoping device-code as its own follow-up (the SP doc already proposes
|
||||
the `POST /api/integration/device-code` + `…/device-token` pair). It is the
|
||||
single biggest UX unlock but is independent of the three endpoints above.
|
||||
|
||||
---
|
||||
|
||||
## 6. Optional — efficient polling (`?updatedSince=`)
|
||||
|
||||
Already flagged in the SP-integration brainstorm under "Where this work lives".
|
||||
Not required for correctness; worth it once many tasks sync.
|
||||
|
||||
- Add an `updatedAt timestamptz` column to `items` (`defaultNow()`, bumped on
|
||||
every mutating write — check/uncheck, assign, edit, restore). One migration +
|
||||
touching the existing item writes to set it.
|
||||
- Accept `GET /api/integration/tasks?updatedSince=<ISO>` → `and(…, gt(items.updatedAt, since))`.
|
||||
- Add `updatedAt` to the `SPTask` DTO so SP can store a high-water mark.
|
||||
|
||||
SP would then poll with its last-seen timestamp instead of refetching the full
|
||||
assigned set each interval. Defer until the read volume justifies it.
|
||||
|
||||
---
|
||||
|
||||
## 7. Shared types to add (`packages/shared/src/types.ts`)
|
||||
|
||||
```ts
|
||||
// response of POST /api/integration/tasks/:id/claim
|
||||
export interface SPClaimTaskResponse {
|
||||
task: SPTask;
|
||||
}
|
||||
|
||||
// response of GET /api/integration/claimable-tasks (can reuse SPTasksResponse)
|
||||
export type SPClaimableTasksResponse = SPTasksResponse;
|
||||
|
||||
// response of POST /api/integration/spaces
|
||||
export interface SPCreateSpaceResponse {
|
||||
project: Pick<Project, 'id' | 'slug' | 'name' | 'purpose' | 'sharingMode'>;
|
||||
memberId: string;
|
||||
}
|
||||
```
|
||||
|
||||
And in `packages/shared/src/validation.ts`:
|
||||
|
||||
```ts
|
||||
export const CreateSpaceViaTokenSchema = z.object({
|
||||
name: z.string().min(1).max(MAX_PROJECT_NAME_LENGTH),
|
||||
purpose: z.string().max(MAX_PURPOSE_LENGTH).default(''),
|
||||
displayName: z.string().min(1).max(MAX_DISPLAY_NAME_LENGTH).optional(),
|
||||
});
|
||||
```
|
||||
|
||||
`SPTask` itself is unchanged (add `updatedAt` only if §6 is taken).
|
||||
|
||||
---
|
||||
|
||||
## 8. Tests (mirror `routes/integration.test.ts`)
|
||||
|
||||
`integration.test.ts` already has the harness (PAT minting + `app.request`).
|
||||
Add, in the same style:
|
||||
|
||||
**claim**
|
||||
|
||||
- claims an unassigned task → `200`, row `assignedTo === myMember`, `item.assigned`
|
||||
activity row written, SSE `item.updated` emitted.
|
||||
- claiming an already-assigned task → `409`, row unchanged.
|
||||
- claiming a task in a project I'm **not** a member of → `404` (isolation).
|
||||
- two concurrent claims → exactly one `200`, one `409` (atomic-update race).
|
||||
- self-assignment does **not** insert an `assignmentNotifications` row.
|
||||
|
||||
**claimable-tasks**
|
||||
|
||||
- returns only `assignedTo IS NULL AND checked = false AND deletedAt IS NULL`
|
||||
within my projects; excludes mine/others'/done/deleted.
|
||||
- `?projectId=` outside my scope returns `[]` (no foreign-project probe).
|
||||
|
||||
**create-space**
|
||||
|
||||
- `201`; `projects` + creator `members` + default `lists`/`scratchpads` rows
|
||||
exist; **the same PAT** can immediately `GET /tasks` scoped to the new project.
|
||||
- validation failure → `422`; over-limit → `429`.
|
||||
|
||||
---
|
||||
|
||||
## 9. How SP consumes each (so the contract is mutually legible)
|
||||
|
||||
All isolated in SP's `PlainspaceApiService` (one file) — see
|
||||
`docs/plainspace-integration-plan.md`:
|
||||
|
||||
| SP method | Endpoint | Notes |
|
||||
| --------------------------- | -------------------------------------------- | ----------------------------------------------- |
|
||||
| `getMe$` / `testConnection` | `GET /me` | identity + space list |
|
||||
| `getMyTasks$` | `GET /tasks` | client-filters `task.projectId === cfg.spaceId` |
|
||||
| `getById$` / poll | `GET /tasks/:id` | freshness for imported tasks |
|
||||
| done write-back | `PATCH /tasks/:id { done }` | on SP task complete/reopen |
|
||||
| scheduled-time sync | `PATCH /tasks/:id { scheduledAt }` + read | `dueWithTime ↔ scheduledAt` (§4b) |
|
||||
| `getUnclaimedTasks$` | `GET /claimable-tasks?projectId=cfg.spaceId` | claim pool feed |
|
||||
| `claimTask$` | `POST /tasks/:id/claim` | then `addTaskFromIssue` imports it |
|
||||
| `createSpace$` | `POST /spaces` | bind provider `spaceId = project.id` |
|
||||
|
||||
Two **client-side** fixes SP must make when going real (server unaffected, noting
|
||||
for completeness): send `Authorization: Bearer <PAT>` on every call (the PAT
|
||||
lives in `PlainspaceCfg`, not a mock account), and use `SPTask.url` directly for
|
||||
"open in Plainspace" instead of constructing `…/spaces/:id/tasks/:id` (the real
|
||||
link is `itemUrl` = `{origin}/{slug}/item/{id}`).
|
||||
|
||||
---
|
||||
|
||||
## 10. Out of scope (separate plans)
|
||||
|
||||
- **Device-code auth** (`/api/integration/device-code` + `/device-token`) — the
|
||||
real onboarding fix; see §5 and the SP brainstorm doc.
|
||||
- **SP → Plainspace promotion** (assign an SP task to someone → seed a Space +
|
||||
invite) — the dominant flow in the product vision, larger than this PR's needs.
|
||||
- **Assignee/“waiting-on” surfacing**, presence, comments, attachments.
|
||||
- Per-occurrence reminders / repeat rules over the integration channel.
|
||||
|
||||
---
|
||||
|
||||
## 11. Summary — minimum to unblock SP PR #8424
|
||||
|
||||
1. `POST /api/integration/tasks/:taskId/claim` (§2) — **required** for the claim pool.
|
||||
2. `GET /api/integration/claimable-tasks` (§3) — **required** for the claim pool.
|
||||
3. `POST /api/integration/spaces` (§4) — **required** for "Share on Plainspace"
|
||||
(additional Spaces; first-Space onboarding waits on device-code, §5).
|
||||
4. `scheduledAt`/`isRecurring` on `serializeSPTask` + `scheduledAt` on
|
||||
`PATCH /tasks/:id` (§4b) — **required** for scheduled-time sync. No new
|
||||
endpoint/table; extends the read DTO + PATCH via the in-app `applyRepeatUpdate`
|
||||
path (DTO `scheduledAt` ↔ db `remindAt`).
|
||||
5. `?updatedSince=` + `items.updatedAt` (§6) — **optional**, polling efficiency.
|
||||
|
||||
All three required endpoints are ~1 handler each, reuse `apiTokenMiddleware` /
|
||||
`loadIntegrationScope` / `serializeSPTask` / `recordActivity` / `sseManager`, and
|
||||
add no new tables. Estimated surface: one new file or ~150 lines appended to
|
||||
`routes/integration.ts`, a handful of shared-type lines, and the tests in §8.
|
||||
458
docs/plainspace-integration-plan.md
Normal file
458
docs/plainspace-integration-plan.md
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
# Plainspace Integration Plan
|
||||
|
||||
Integrating **Plainspace** (plainspace.org — repo `Johannesjo/spaces`) into Super
|
||||
Productivity (SP) so that:
|
||||
|
||||
1. A project can be made **shared on Plainspace** directly from the project
|
||||
create/edit dialog.
|
||||
2. For such shared projects, SP shows — by **task ownership** (see the model in
|
||||
§1):
|
||||
- **My list** — tasks assigned to me, as regular, editable SP tasks.
|
||||
- **A read‑only claim pool** — unclaimed (unassigned) tasks you can **claim**;
|
||||
claiming assigns the task to you in Plainspace and imports it as an SP task.
|
||||
- Tasks **assigned to others are not represented in SP** at all.
|
||||
|
||||
> **Conceptual note (revised):** an earlier draft mirrored "assigned to others"
|
||||
> into SP as a standing read-only list. We dropped that — SP is a personal focus
|
||||
> tool, and a permanent wall of others' non-actionable tasks works against it and
|
||||
> creates a stale second copy of the Plainspace board. The model is now: **only
|
||||
> _mine_ + _unclaimed_ appear in SP; claiming is the bridge** that turns shared
|
||||
> work into your work. See the conversation rationale captured in §7.
|
||||
|
||||
> Status: planning + prototype. The Plainspace HTTP API contract is not yet
|
||||
> pinned down in this document (see [Open questions](#10-open-questions--blocking-decisions));
|
||||
> the prototype is built against an **assumed contract** isolated behind a single
|
||||
> API service so it can be corrected in one place once the real API is known.
|
||||
>
|
||||
> **Implemented today (mock-backed):**
|
||||
>
|
||||
> - §4 — the `PLAINSPACE` issue provider (`providers/plainspace/`): config form,
|
||||
> `PlainspaceApiService` (mock mode via `PLAINSPACE_USE_MOCK`),
|
||||
> `PlainspaceCommonInterfacesService` implementing `IssueServiceInterface`,
|
||||
> registered in `issue.model.ts` / `issue.const.ts` / `issue.service.ts` +
|
||||
> icon. **Only tasks assigned to me** import via the issue→backlog pipeline.
|
||||
> - §5 — account / identity: `PlainspaceAccountService` (signals: `account`,
|
||||
> `isLoggedIn`, `currentUserId`; localStorage-persisted, never synced) with a
|
||||
> mock `login`/`logout`. "Mine" comes from the signed-in identity, and the
|
||||
> share toggle prompts sign-in if needed.
|
||||
> - §6 — the "Share on Plainspace" toggle in the create-project dialog, which
|
||||
> (after sign-in) provisions a (mock) space and a bound provider via
|
||||
> `PlainspaceShareService`.
|
||||
> - §7 — the read-only **claim pool**: `PlainspaceClaimPoolService` feeds
|
||||
> unclaimed tasks (mock) through `project-task-page` → `work-view` into a
|
||||
> collapsed-by-default panel (`PlainspaceClaimPoolComponent`). A **Claim**
|
||||
> action assigns the task to me and imports it as an SP task. Shows only for
|
||||
> shared projects.
|
||||
>
|
||||
> **Still design-only:** §8 write-back, and the real HTTP API + real auth (all
|
||||
> `PlainspaceApiService` calls, the login, and claim are mocked — see §10). The
|
||||
> claim pool does not yet auto-poll (loads on project open / provider change /
|
||||
> after a claim).
|
||||
|
||||
---
|
||||
|
||||
## 1. Guiding decisions (agreed)
|
||||
|
||||
| Decision | Choice |
|
||||
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Source of truth** for shared data | **Plainspace backend.** SP reads/writes shared tasks via Plainspace's API as a separate channel from SP's own op‑log sync. |
|
||||
| **Integration shape** | Model Plainspace as a **regular issue provider** (like Jira/Redmine) "for the most part". |
|
||||
| **Identity** | **Plainspace account login** (token-based). The authenticated account defines "me" — which tasks are mine vs unclaimed. |
|
||||
| **v1 scope** | Plan the full feature; build a working **prototype** (UI + provider scaffold against an assumed/mock API). |
|
||||
|
||||
### Why "issue provider" is the right host
|
||||
|
||||
SP already has a mature, well-factored issue-provider system (Jira, GitLab,
|
||||
CalDAV, OpenProject, Trello, Redmine, Azure DevOps, Nextcloud Deck). It gives us,
|
||||
for free:
|
||||
|
||||
- Per-provider config + Formly config form, stored in the issue-provider NgRx
|
||||
store and bindable to a specific project via `defaultProjectId`.
|
||||
- Search in the add-task bar, "add issue as task", attachments mapping.
|
||||
- **Auto-import to backlog** (`getNewIssuesToAddToBacklog`) and **polling for
|
||||
fresh data** (`getFreshDataForIssueTask`) with a configurable `pollInterval`.
|
||||
- A clean single interface to implement: `IssueServiceInterface`.
|
||||
|
||||
This means "Plainspace issues assigned to me" flow through the **existing**
|
||||
issue→task pipeline with almost no new core code. The only genuinely new surface
|
||||
is the read-only **claim pool**, because that data is _not_ imported as SP tasks
|
||||
until claimed.
|
||||
|
||||
### The one important nuance
|
||||
|
||||
The standard issue-provider flow turns issues **into** SP tasks. We only do that
|
||||
for tasks **assigned to me**. Unclaimed tasks are _shown_ (a read-only pool) but
|
||||
**not** auto-imported — claiming is a deliberate act that assigns the task to me
|
||||
and then imports it. Tasks assigned to others are not represented in SP at all.
|
||||
(Auto-importing _unclaimed_ work as if it were yours has the same problem as the
|
||||
old others-list, just subtler: two members connecting the same space would both
|
||||
"own" the same unclaimed task locally.) See §7 for the full ownership model.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Plainspace API │
|
||||
│ spaces (projects) · tasks · members · auth │
|
||||
└───────────────┬───────────────┬─────────────┘
|
||||
│ │
|
||||
(issue-provider channel) (shared-project channel)
|
||||
│ │
|
||||
┌────────────────────────────────▼──┐ ┌────────▼───────────────────────────┐
|
||||
│ PlainspaceApiService (HTTP) │ │ PlainspaceAccountService (auth/me) │
|
||||
│ PlainspaceCommonInterfacesService │ │ PlainspaceClaimPoolService │
|
||||
│ implements IssueServiceInterface │ │ (unclaimed tasks + claim action) │
|
||||
└───────────────┬────────────────────┘ └───────────────┬────────────────────┘
|
||||
│ │
|
||||
┌───────────────▼────────────────┐ ┌────────────────▼───────────────────┐
|
||||
│ Existing issue→task pipeline │ │ Read-only claim-pool panel │
|
||||
│ → real SP tasks (assigned to me)│ claim │ in work-view (unclaimed tasks) │
|
||||
└─────────────────────────────────┘◄───────┴─────────────────────────────────────┘
|
||||
│ │
|
||||
└──────────────► Project work view ◄───────┘
|
||||
(My list) (Claim pool)
|
||||
```
|
||||
|
||||
- **Issue-provider channel** = the Jira-like path. Registers a `PLAINSPACE`
|
||||
provider, bound per project via `defaultProjectId` (= the SP project the space
|
||||
maps to). Imports **assigned-to-me** issues, polls them for freshness.
|
||||
- **Shared-project channel** = the new bits: account login, the unclaimed claim
|
||||
pool (+ claim → import), and creating a space when a project is shared.
|
||||
|
||||
SP's own op-log/vector-clock sync is **untouched**: shared data does not flow
|
||||
through it. (Doing so would mean teaching the single-user op-log to carry
|
||||
multi-user ops — explicitly rejected as too risky.)
|
||||
|
||||
---
|
||||
|
||||
## 3. Data model changes
|
||||
|
||||
### 3.1 New issue-provider config (`PlainspaceCfg`)
|
||||
|
||||
New folder `src/app/features/issue/providers/plainspace/`. Config interface (mirrors
|
||||
`RedmineCfg`):
|
||||
|
||||
```ts
|
||||
export interface PlainspaceCfg extends BaseIssueProviderCfg {
|
||||
host: string | null; // plainspace.org or self-hosted base URL
|
||||
spaceId: string | null; // the Plainspace "space" this provider is bound to
|
||||
token?: string | null; // PAT (pat_…) authorizing this provider's API calls
|
||||
}
|
||||
```
|
||||
|
||||
> **Where the token lives (as built).** The PAT is stored on `PlainspaceCfg.token`
|
||||
> and authorizes every `PlainspaceApiService` call — exactly like Jira's
|
||||
> `password` or CalDAV's `password`, and like them it is part of synced
|
||||
> issue-provider state and is included in plaintext backups/exports. This is a
|
||||
> deliberate parity choice (a provider works on a fresh device after sync without
|
||||
> re-pasting), and is the accepted secret-handling posture for issue providers.
|
||||
> The account store (§3.3, local-only `localStorage`) holds a token **too**, but
|
||||
> only to bootstrap the "Share on Plainspace" flow, which needs a token *before*
|
||||
> any provider exists; the provider runtime reads only `cfg.token`. An earlier
|
||||
> draft said the token was not stored in the cfg — that was never the case in the
|
||||
> shipped code.
|
||||
|
||||
### 3.2 Plainspace issue/task shapes (assumed — single source to fix later)
|
||||
|
||||
```ts
|
||||
// src/app/features/issue/providers/plainspace/plainspace-issue.model.ts
|
||||
export interface PlainspaceMember {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export interface PlainspaceIssue {
|
||||
id: string;
|
||||
title: string;
|
||||
isDone: boolean;
|
||||
assigneeId: string | null; // null = unassigned
|
||||
assignee?: PlainspaceMember | null;
|
||||
updatedAt: string; // ISO
|
||||
url?: string;
|
||||
// ...extend once the real API is known
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Account / identity (new, small store)
|
||||
|
||||
```ts
|
||||
// src/app/features/plainspace/plainspace-account.model.ts
|
||||
export interface PlainspaceAccount {
|
||||
host: string; // which plainspace instance
|
||||
userId: string; // "me"
|
||||
displayName: string;
|
||||
token: string; // bearer token (stored like other provider creds)
|
||||
}
|
||||
```
|
||||
|
||||
Stored per SP profile alongside other credentials (same mechanism existing
|
||||
providers use for secrets). One account → many spaces.
|
||||
|
||||
### 3.4 No change to the SP `Task` model in v1
|
||||
|
||||
- "Mine/unassigned" tasks are normal SP tasks; their Plainspace origin is already
|
||||
captured by the existing `issueId` / `issueProviderId` / `issueType` fields.
|
||||
- "Assigned to others" tasks are **not** SP tasks, so they need no `Task` field.
|
||||
An `assignee` field on SP tasks is **explicitly deferred** (would touch the
|
||||
hot-path task component and sync) — see [Future work](#11-future-work).
|
||||
|
||||
### 3.5 Project ↔ space link
|
||||
|
||||
The link is expressed entirely through the issue-provider instance:
|
||||
`IssueProviderPlainspace.defaultProjectId` = SP project id, and
|
||||
`PlainspaceCfg.spaceId` = remote space id. No new field on `Project` is strictly
|
||||
required. (Optional convenience flag `Project.isSharedOnPlainspace` could be added
|
||||
later for menu/badge rendering, but is not needed for correctness.)
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase 1 — Plainspace issue provider scaffold
|
||||
|
||||
Goal: `PLAINSPACE` exists as a first-class issue provider; my/unassigned issues
|
||||
import as tasks and poll. Pattern reference: **Redmine** (simplest built-in).
|
||||
|
||||
### 4.1 Central registration (4 edits)
|
||||
|
||||
- `src/app/features/issue/issue.model.ts`
|
||||
- add `'PLAINSPACE'` to `BuiltInIssueProviderKey` + `BUILT_IN_KEYS`
|
||||
- add `PlainspaceCfg` to `IssueIntegrationCfg` union and
|
||||
`IssueIntegrationCfgs` map
|
||||
- add issue type to `IssueData` / `IssueDataReduced` (+ `IssueDataReducedMap`)
|
||||
- add `IssueProviderPlainspace extends IssueProviderBase, PlainspaceCfg`
|
||||
(`issueProviderKey: 'PLAINSPACE'`) and add it to the `IssueProvider` union and
|
||||
`IssueProviderTypeMap`.
|
||||
- `src/app/features/issue/issue.const.ts`
|
||||
- `PLAINSPACE_TYPE`, add to `ISSUE_PROVIDER_TYPES`,
|
||||
`ISSUE_PROVIDER_ICON_MAP`, `ISSUE_PROVIDER_HUMANIZED`,
|
||||
`DEFAULT_ISSUE_PROVIDER_CFGS`, `ISSUE_PROVIDER_FORM_CFGS_MAP`, `ISSUE_STR_MAP`.
|
||||
- `src/app/features/issue/issue.service.ts`
|
||||
- import + inject `PlainspaceCommonInterfacesService`, add to
|
||||
`ISSUE_SERVICE_MAP`.
|
||||
- Provider icon: add `src/assets/icons/plainspace.svg` **and** register it in
|
||||
`GlobalThemeService` (`_initIcons()`, the `addSvgIcon(...)` block) — the
|
||||
`ISSUE_PROVIDER_ICON_MAP` value only names the icon, it does not register it.
|
||||
Note `ISSUE_PROVIDER_HUMANIZED` is a plain string ('Plainspace'), not a `T`
|
||||
key, so no translation entry is needed for the provider name itself.
|
||||
|
||||
> Not strictly 4 files: adding `'PLAINSPACE'` to `BuiltInIssueProviderKey` also
|
||||
> widens `IssueProviderKey`, so the existing `Task.issueType` field gains
|
||||
> `'PLAINSPACE'` as a valid value. No new `Task` field, but it is a (safe,
|
||||
> additive) type-surface change to be aware of.
|
||||
|
||||
### 4.2 New provider files (`providers/plainspace/`)
|
||||
|
||||
| File | Responsibility |
|
||||
| ----------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `plainspace.model.ts` | `PlainspaceCfg` |
|
||||
| `plainspace-issue.model.ts` | `PlainspaceIssue`, `PlainspaceMember` |
|
||||
| `plainspace.const.ts` | `DEFAULT_PLAINSPACE_CFG`, `PLAINSPACE_POLL_INTERVAL` |
|
||||
| `plainspace-cfg-form.const.ts` | Formly config form + `..._CONFIG_FORM_SECTION` (host, advanced common fields) |
|
||||
| `plainspace-api.service.ts` | All HTTP: `searchIssues$`, `getById$`, `getTasksForSpace$`, `getMembers$`, `createSpace$`, plus mock mode |
|
||||
| `plainspace-common-interfaces.service.ts` | implements `IssueServiceInterface` (extends `BaseIssueProviderService`) |
|
||||
| `plainspace-issue-map.util.ts` | `PlainspaceIssue → SearchResultItem` and `→ getAddTaskData` |
|
||||
|
||||
### 4.3 `IssueServiceInterface` implementation notes
|
||||
|
||||
- `isEnabled(cfg)` → `cfg.isEnabled && !!cfg.host && !!cfg.spaceId` and an account
|
||||
token present.
|
||||
- `getAddTaskData(issue)` → `{ title, isDone, issuePoints? }`. **Filter at the
|
||||
source**: only my/unassigned issues are ever offered to this path (see 4.4).
|
||||
- `getNewIssuesToAddToBacklog(providerId, existingIds)` → fetch space tasks
|
||||
where `assigneeId === me || assigneeId == null`, minus `existingIds`.
|
||||
- `getFreshDataForIssueTask(task)` → re-fetch by id, return `isDone`/title
|
||||
changes (never overwrite user scheduling).
|
||||
- `pollInterval` → `PLAINSPACE_POLL_INTERVAL` (e.g. 5 min). Reuses existing
|
||||
`poll-issue-updates.effects.ts` and `poll-to-backlog.effects.ts`.
|
||||
|
||||
### 4.4 The mine/unassigned filter
|
||||
|
||||
Centralize in `plainspace-api.service` (`getMyAndUnassignedTasks$`) so both the
|
||||
backlog import and the search path only ever see items that are valid to import.
|
||||
"Assigned to others" is fetched by a sibling method and never reaches the issue
|
||||
pipeline.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase 2 — Account login / identity
|
||||
|
||||
Goal: establish "me" so the assigned/unassigned split is meaningful.
|
||||
|
||||
- `src/app/features/plainspace/plainspace-account.service.ts` — login (token
|
||||
exchange), store/clear account, expose `me$` (signal) and `currentUserId`.
|
||||
- Login UI: a button in the Plainspace provider config form (`testConnection`
|
||||
doubles as "verify login"), and/or a small dialog. Token persisted via the same
|
||||
secret-storage path other providers use.
|
||||
- `isEnabled` and the shared-tasks fetch both depend on a valid account; surface a
|
||||
clear "not logged in" state.
|
||||
|
||||
Auth mechanism (token vs OAuth redirect) depends on what Plainspace exposes — see
|
||||
[Open questions](#10-open-questions--blocking-decisions). If OAuth is required,
|
||||
reuse the existing `src/app/plugins/oauth/` helpers.
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase 3 — "Share on Plainspace" in the project create/edit dialog
|
||||
|
||||
Goal: a toggle in `dialog-create-project` that provisions a Plainspace space and
|
||||
wires up the provider binding.
|
||||
|
||||
- **Form**: add an `isShareOnPlainspace` checkbox to
|
||||
`CREATE_PROJECT_BASIC_CONFIG_FORM_CONFIG`
|
||||
(`src/app/features/project/project-form-cfg.const.ts`). Gate it behind "account
|
||||
logged in" (show a login affordance if not).
|
||||
- **On submit** (in `dialog-create-project.component.ts`), after the project is
|
||||
created via `projectService.add()`:
|
||||
1. If `isShareOnPlainspace` and logged in → `PlainspaceApiService.createSpace$(
|
||||
{ title })` → returns `spaceId`.
|
||||
2. Create a `PLAINSPACE` issue-provider instance with
|
||||
`{ host, spaceId, isEnabled: true, defaultProjectId: <newProjectId>,
|
||||
isAutoAddToBacklog: true }` via the issue-provider store.
|
||||
- **Edit mode**: same toggle reflects whether a bound Plainspace provider exists;
|
||||
turning it on later provisions the space + provider; turning it off should
|
||||
prompt (unlink vs delete remote) — keep v1 to **unlink only** (disable provider,
|
||||
leave remote space intact) to avoid destructive surprises.
|
||||
- **i18n**: new strings via `T`/`en.json` only.
|
||||
|
||||
---
|
||||
|
||||
## 7. Ownership model & the claim pool (revised)
|
||||
|
||||
**The conceptual question that reshaped this feature:** _which tasks from a
|
||||
shared space should appear in a personal focus app at all?_ Answer — by
|
||||
ownership state:
|
||||
|
||||
| Plainspace state | In SP? | Treatment |
|
||||
| -------------------- | ------------------- | ------------------------------------------------------------- |
|
||||
| Assigned to me | Yes — the point | First-class SP tasks: schedule, time-track, complete. |
|
||||
| Unclaimed | As a pool, not list | Read-only "claim pool"; **Claim** → assign-to-self → SP task. |
|
||||
| Assigned to others | No | Not represented in SP (Plainspace board is the team view). |
|
||||
| Done (others/unass.) | No | Irrelevant to the individual. |
|
||||
|
||||
**Claiming is the bridge** between the collective space and the personal app:
|
||||
the only way unclaimed work becomes yours. This avoids mirroring a stale,
|
||||
non-actionable copy of other people's tasks into a focus tool. (We also skip
|
||||
per-task assignee badges: with only _mine_ + _unclaimed_ shown, ownership is
|
||||
implicit, and SP already shows a provider icon on issue-linked tasks.)
|
||||
|
||||
### 7.1 Read-only claim-pool component
|
||||
|
||||
`src/app/features/plainspace/claim-pool/claim-pool.component.ts`
|
||||
|
||||
- Input: `tasks: PlainspaceSharedTask[]` (unclaimed) + `projectId`.
|
||||
- Flat read-only rows: title, "open in Plainspace" link, **Claim** button. No
|
||||
drag/drop, scheduling, time tracking, or task-store interaction.
|
||||
|
||||
> Deliberately a **lightweight standalone component**, not the hot-path
|
||||
> `task.component` — these are foreign tasks until claimed.
|
||||
|
||||
### 7.2 Data flow
|
||||
|
||||
- `PlainspaceClaimPoolService.unclaimedTasksForProject$(projectId)` finds the
|
||||
bound enabled `PLAINSPACE` provider and returns its unclaimed tasks
|
||||
(`assigneeId === null && !isDone`), refreshing after a claim.
|
||||
- `claim(projectId, taskId)` → `PlainspaceApiService.claimTask$` (assign-to-me)
|
||||
→ `IssueService.addTaskFromIssue(... isAddToBacklog)` → pool refreshes.
|
||||
- `project-task-page` derives `unclaimedTasks` and passes it (+ `projectId`)
|
||||
into `work-view`.
|
||||
|
||||
### 7.3 Layout
|
||||
|
||||
A `collapsible` section (mirrors overdue/done panels), **collapsed by default**
|
||||
(it's a pool you reach for, not your active work); state persisted in
|
||||
`localStorage` (`LS.PLAINSPACE_CLAIM_POOL_HIDDEN`).
|
||||
|
||||
---
|
||||
|
||||
## 8. Phase 5 — Polling, refresh & write-back
|
||||
|
||||
- **Reads**: reuse issue polling for my tasks; add a light timer in
|
||||
`PlainspaceClaimPoolService` to refresh the claim pool (same interval), only
|
||||
while the shared project is open. Today it refreshes on project open / provider
|
||||
change / after a claim — no timer yet.
|
||||
- **Writes (mine)**: completing/editing a _my_ imported task should optionally
|
||||
push back to Plainspace via `updateIssueFromTask` (the optional interface hook).
|
||||
Start **read-mostly**: import + status sync for done-state only; expand later.
|
||||
- **Claim**: the one write already implemented — assign an unclaimed task to me
|
||||
(`claimTask$`), then import it. Real mode would POST the assignment.
|
||||
- **Offline**: all Plainspace calls must fail soft (empty lists, cached last
|
||||
values) and never block the SP UI; SP remains fully usable offline.
|
||||
|
||||
---
|
||||
|
||||
## 9. Prototype scope (this iteration)
|
||||
|
||||
A runnable prototype that demonstrates the UX end-to-end against a **mock**
|
||||
Plainspace backend (toggled by a flag in `PlainspaceApiService`), so it works with
|
||||
no live server and is trivially swapped for the real API:
|
||||
|
||||
1. `PLAINSPACE` provider registered + config form (Phase 1 skeleton).
|
||||
2. `PlainspaceApiService` with a **mock mode** returning canned spaces, members,
|
||||
and tasks (mix of mine / unassigned / others).
|
||||
3. "Share on Plainspace" toggle in the create dialog that, in mock mode, fakes
|
||||
space creation and provisions the provider binding (Phase 3).
|
||||
4. The **"Assigned to others"** read-only panel wired into the work view for
|
||||
shared projects (Phase 4) — the visually novel part.
|
||||
5. My/unassigned issues importing into the normal list via the issue pipeline.
|
||||
|
||||
Out of prototype scope: real auth handshake, write-back, attachments, subtasks,
|
||||
production error/empty states polish.
|
||||
|
||||
---
|
||||
|
||||
## 10. Open questions / blocking decisions
|
||||
|
||||
These need answers (ideally the Plainspace API docs / the `Johannesjo/spaces`
|
||||
repo, which I could not access from this environment) to move the prototype onto
|
||||
the real backend:
|
||||
|
||||
1. **Auth**: token/API-key, or OAuth redirect flow? Endpoint(s)? How is "me"
|
||||
(current user id) returned?
|
||||
2. **Spaces API**: create space (`POST`?), list spaces, get members of a space.
|
||||
3. **Tasks API**: list/get tasks for a space; fields available (esp. `assigneeId`,
|
||||
done state, ordering); search; pagination.
|
||||
4. **Write-back**: can SP create/update tasks and assignments? Required for the
|
||||
"share" flow to push SP tasks up, vs. pull-only.
|
||||
5. **Hosting**: is it always plainspace.org, or self-hostable (host field needed)?
|
||||
6. **CORS/Electron**: does the API allow browser-origin calls, or must requests go
|
||||
through the Electron main process (like some providers do)?
|
||||
|
||||
## 11. Future work
|
||||
|
||||
- Optional `Project.isSharedOnPlainspace` flag for menu badges.
|
||||
- A real `assignee` concept on SP tasks (hot-path + sync implications — separate
|
||||
design).
|
||||
- Reassign-from-SP, presence/avatars, comments, two-way task creation.
|
||||
|
||||
## 12. Risks
|
||||
|
||||
- **Cross-client forward-compat (rollout-gating).** Adding `'PLAINSPACE'` to the
|
||||
built-in `IssueProviderKey` union widens a **synced, typia-validated** type
|
||||
(`issueProvider` state, and `task.issueType`). A client built before this change
|
||||
has an AOT-baked validator that does not know the `'PLAINSPACE'` literal, so
|
||||
when a newer client creates a Plainspace provider and syncs it, the older client
|
||||
will reject the incoming model as corrupt (the documented "typia rejects unknown
|
||||
union members" failure → false data-corruption dialog / rejected sync). This is
|
||||
inherent to adding any new built-in provider key, and the mitigation is
|
||||
**release sequencing**: the validator-aware build must reach the fleet **before**
|
||||
any client can emit a `PLAINSPACE` provider. Do not back-port the ability to
|
||||
create a Plainspace provider to a client that can't validate the key. (The
|
||||
alternative — modelling Plainspace as a `plugin:`-style opaque key, which the
|
||||
validator already accepts — is forward-compatible but is a larger change and is
|
||||
the wrong shape for a built-in provider; revisit only if simultaneous rollout
|
||||
can't be guaranteed.)
|
||||
- **Sync correctness**: keep shared data **out** of the op-log; never route
|
||||
Plainspace fetches through NgRx persisted actions. Imported "my" tasks follow
|
||||
the existing, already-correct issue-task path.
|
||||
- **Hot path**: the claim pool is a new lightweight component, not
|
||||
`task.component`; verify against large lists.
|
||||
- **API assumptions**: all isolated in `PlainspaceApiService` +
|
||||
`plainspace-issue.model.ts` so the real contract changes one layer.
|
||||
- **Privacy**: the PAT is stored in synced provider cfg like other provider
|
||||
secrets (see §3.1); no analytics; log only ids (`Log.log({ id })`).
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
|
|
@ -71,6 +71,9 @@ export enum LS {
|
|||
LATER_TODAY_TASKS_HIDDEN = 'LATER_TODAY_TASKS_HIDDEN',
|
||||
OVERDUE_TASKS_HIDDEN = 'OVERDUE_TASKS_HIDDEN',
|
||||
REPEAT_CFGS_HIDDEN = 'REPEAT_CFGS_HIDDEN',
|
||||
PLAINSPACE_CLAIM_POOL_HIDDEN = 'PLAINSPACE_CLAIM_POOL_HIDDEN',
|
||||
// Plainspace account/identity — local-only, never synced (device identity).
|
||||
PLAINSPACE_ACCOUNT = 'SUP_PLAINSPACE_ACCOUNT',
|
||||
|
||||
// Magic side nav
|
||||
NAV_SIDEBAR_EXPANDED = 'SUP_NAV_SIDEBAR_EXPANDED',
|
||||
|
|
|
|||
|
|
@ -205,6 +205,7 @@ export class GlobalThemeService {
|
|||
['trello', 'assets/icons/trello.svg'],
|
||||
['azure_devops', 'assets/icons/azure_devops.svg'],
|
||||
['nextcloud_deck', 'assets/icons/nextcloud_deck.svg'],
|
||||
['plainspace', 'assets/icons/plainspace.svg'],
|
||||
];
|
||||
|
||||
// todo test if can be removed with airplane mode and wifi without internet
|
||||
|
|
|
|||
|
|
@ -117,6 +117,14 @@
|
|||
<mat-icon svgIcon="nextcloud_deck"></mat-icon>
|
||||
<span>Nextcloud Deck</span>
|
||||
</button>
|
||||
<button
|
||||
class="provider-tile focus-ring"
|
||||
type="button"
|
||||
(click)="openSetupDialog('PLAINSPACE')"
|
||||
>
|
||||
<mat-icon svgIcon="plainspace"></mat-icon>
|
||||
<span>Plainspace</span>
|
||||
</button>
|
||||
@for (provider of pluginProviders; track provider.pluginId) {
|
||||
<button
|
||||
class="provider-tile focus-ring"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { OPEN_PROJECT_ISSUE_CONTENT_CONFIG } from '../providers/open-project/ope
|
|||
// Linear is now a plugin — content config lives in the plugin's issueDisplay
|
||||
import { AZURE_DEVOPS_ISSUE_CONTENT_CONFIG } from '../providers/azure-devops/azure-devops-issue/azure-devops-issue-content.const';
|
||||
import { NEXTCLOUD_DECK_ISSUE_CONTENT_CONFIG } from '../providers/nextcloud-deck/nextcloud-deck-issue-content.const';
|
||||
import { PLAINSPACE_ISSUE_CONTENT_CONFIG } from '../providers/plainspace/plainspace-issue-content.const';
|
||||
|
||||
// Re-export types for backwards compatibility
|
||||
export { IssueFieldType, IssueFieldConfig, IssueCommentConfig, IssueContentConfig };
|
||||
|
|
@ -30,6 +31,7 @@ export const ISSUE_CONTENT_CONFIGS: Record<
|
|||
OPEN_PROJECT: OPEN_PROJECT_ISSUE_CONTENT_CONFIG,
|
||||
AZURE_DEVOPS: AZURE_DEVOPS_ISSUE_CONTENT_CONFIG,
|
||||
NEXTCLOUD_DECK: NEXTCLOUD_DECK_ISSUE_CONTENT_CONFIG,
|
||||
PLAINSPACE: PLAINSPACE_ISSUE_CONTENT_CONFIG,
|
||||
ICAL: {
|
||||
issueType: 'ICAL',
|
||||
fields: [],
|
||||
|
|
|
|||
|
|
@ -29,4 +29,8 @@
|
|||
[task]="task()"
|
||||
></caldav-time>
|
||||
}
|
||||
@case ('plainspace-recurring') {
|
||||
<!-- Static badge; the field's label cell already names it "Recurring task". -->
|
||||
<mat-icon>repeat</mat-icon>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,19 @@ import { OpenProjectAttachmentsComponent } from './open-project-attachments/open
|
|||
import { JiraLinkComponent } from './jira-link/jira-link.component';
|
||||
import { TaskCopy } from '../../../tasks/task.model';
|
||||
import { CaldavTimeComponent } from './caldav-time/caldav-time.component';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
|
||||
@Component({
|
||||
selector: 'issue-content-custom',
|
||||
templateUrl: './issue-content-custom.component.html',
|
||||
styleUrls: ['./issue-content-custom.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [OpenProjectAttachmentsComponent, JiraLinkComponent, CaldavTimeComponent],
|
||||
imports: [
|
||||
OpenProjectAttachmentsComponent,
|
||||
JiraLinkComponent,
|
||||
CaldavTimeComponent,
|
||||
MatIcon,
|
||||
],
|
||||
})
|
||||
export class IssueContentCustomComponent {
|
||||
readonly field = input.required<IssueFieldConfig>();
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ import { AZURE_DEVOPS_INITIAL_CFG } from './providers/azure-devops/azure-devops.
|
|||
import { DEFAULT_NEXTCLOUD_DECK_CFG } from './providers/nextcloud-deck/nextcloud-deck.const';
|
||||
import { AZURE_DEVOPS_CONFIG_FORM_SECTION } from './providers/azure-devops/azure-devops-cfg-form.const';
|
||||
import { NEXTCLOUD_DECK_CONFIG_FORM_SECTION } from './providers/nextcloud-deck/nextcloud-deck.const';
|
||||
import {
|
||||
DEFAULT_PLAINSPACE_CFG,
|
||||
PLAINSPACE_CONFIG_FORM_SECTION,
|
||||
} from './providers/plainspace/plainspace.const';
|
||||
|
||||
export const DELAY_BEFORE_ISSUE_POLLING = 8000;
|
||||
|
||||
|
|
@ -48,6 +52,7 @@ export const TRELLO_TYPE: MigratedIssueProviderKey = 'TRELLO';
|
|||
export const CLICKUP_TYPE: MigratedIssueProviderKey = 'CLICKUP';
|
||||
export const AZURE_DEVOPS_TYPE: BuiltInIssueProviderKey = 'AZURE_DEVOPS';
|
||||
export const NEXTCLOUD_DECK_TYPE: BuiltInIssueProviderKey = 'NEXTCLOUD_DECK';
|
||||
export const PLAINSPACE_TYPE: BuiltInIssueProviderKey = 'PLAINSPACE';
|
||||
|
||||
export const ISSUE_PROVIDER_TYPES: BuiltInIssueProviderKey[] = [
|
||||
GITLAB_TYPE,
|
||||
|
|
@ -58,6 +63,7 @@ export const ISSUE_PROVIDER_TYPES: BuiltInIssueProviderKey[] = [
|
|||
REDMINE_TYPE,
|
||||
AZURE_DEVOPS_TYPE,
|
||||
NEXTCLOUD_DECK_TYPE,
|
||||
PLAINSPACE_TYPE,
|
||||
] as const;
|
||||
|
||||
export const ISSUE_PROVIDER_ICON_MAP = {
|
||||
|
|
@ -69,6 +75,7 @@ export const ISSUE_PROVIDER_ICON_MAP = {
|
|||
[REDMINE_TYPE]: 'redmine',
|
||||
[AZURE_DEVOPS_TYPE]: 'azure_devops',
|
||||
[NEXTCLOUD_DECK_TYPE]: 'nextcloud_deck',
|
||||
[PLAINSPACE_TYPE]: 'plainspace',
|
||||
} as const;
|
||||
|
||||
export const ISSUE_PROVIDER_HUMANIZED = {
|
||||
|
|
@ -80,6 +87,7 @@ export const ISSUE_PROVIDER_HUMANIZED = {
|
|||
[REDMINE_TYPE]: 'Redmine',
|
||||
[AZURE_DEVOPS_TYPE]: 'Azure DevOps',
|
||||
[NEXTCLOUD_DECK_TYPE]: 'Nextcloud Deck',
|
||||
[PLAINSPACE_TYPE]: 'Plainspace',
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_ISSUE_PROVIDER_CFGS = {
|
||||
|
|
@ -91,6 +99,7 @@ export const DEFAULT_ISSUE_PROVIDER_CFGS = {
|
|||
[REDMINE_TYPE]: DEFAULT_REDMINE_CFG,
|
||||
[AZURE_DEVOPS_TYPE]: AZURE_DEVOPS_INITIAL_CFG,
|
||||
[NEXTCLOUD_DECK_TYPE]: DEFAULT_NEXTCLOUD_DECK_CFG,
|
||||
[PLAINSPACE_TYPE]: DEFAULT_PLAINSPACE_CFG,
|
||||
} as const;
|
||||
|
||||
export const ISSUE_PROVIDER_FORM_CFGS_MAP = {
|
||||
|
|
@ -102,6 +111,7 @@ export const ISSUE_PROVIDER_FORM_CFGS_MAP = {
|
|||
[REDMINE_TYPE]: REDMINE_CONFIG_FORM_SECTION,
|
||||
[AZURE_DEVOPS_TYPE]: AZURE_DEVOPS_CONFIG_FORM_SECTION,
|
||||
[NEXTCLOUD_DECK_TYPE]: NEXTCLOUD_DECK_CONFIG_FORM_SECTION,
|
||||
[PLAINSPACE_TYPE]: PLAINSPACE_CONFIG_FORM_SECTION,
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_ISSUE_STRS: { ISSUE_STR: string; ISSUES_STR: string } = {
|
||||
|
|
@ -127,6 +137,7 @@ export const ISSUE_STR_MAP: Record<
|
|||
[REDMINE_TYPE]: DEFAULT_ISSUE_STRS,
|
||||
[AZURE_DEVOPS_TYPE]: DEFAULT_ISSUE_STRS,
|
||||
[NEXTCLOUD_DECK_TYPE]: DEFAULT_ISSUE_STRS,
|
||||
[PLAINSPACE_TYPE]: DEFAULT_ISSUE_STRS,
|
||||
} as const;
|
||||
|
||||
export const ISSUE_PROVIDER_DEFAULT_COMMON_CFG: Omit<
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import {
|
|||
NextcloudDeckIssue,
|
||||
NextcloudDeckIssueReduced,
|
||||
} from './providers/nextcloud-deck/nextcloud-deck-issue.model';
|
||||
import { PlainspaceCfg } from './providers/plainspace/plainspace.model';
|
||||
import { PlainspaceIssue } from './providers/plainspace/plainspace-issue.model';
|
||||
import {
|
||||
PluginIssue,
|
||||
PluginSearchResult,
|
||||
|
|
@ -46,7 +48,8 @@ export type BuiltInIssueProviderKey =
|
|||
| 'OPEN_PROJECT'
|
||||
| 'REDMINE'
|
||||
| 'AZURE_DEVOPS'
|
||||
| 'NEXTCLOUD_DECK';
|
||||
| 'NEXTCLOUD_DECK'
|
||||
| 'PLAINSPACE';
|
||||
|
||||
// Keys migrated from built-in to plugin — still valid as IssueProviderKey
|
||||
export type MigratedIssueProviderKey =
|
||||
|
|
@ -80,6 +83,7 @@ const BUILT_IN_KEYS: ReadonlySet<string> = new Set<BuiltInIssueProviderKey>([
|
|||
'REDMINE',
|
||||
'AZURE_DEVOPS',
|
||||
'NEXTCLOUD_DECK',
|
||||
'PLAINSPACE',
|
||||
]);
|
||||
|
||||
const MIGRATED_KEYS: ReadonlySet<string> = new Set<MigratedIssueProviderKey>([
|
||||
|
|
@ -102,7 +106,8 @@ export type IssueIntegrationCfg =
|
|||
| OpenProjectCfg
|
||||
| RedmineCfg
|
||||
| AzureDevOpsCfg
|
||||
| NextcloudDeckCfg;
|
||||
| NextcloudDeckCfg
|
||||
| PlainspaceCfg;
|
||||
|
||||
export enum IssueLocalState {
|
||||
OPEN = 'OPEN',
|
||||
|
|
@ -120,6 +125,7 @@ export interface IssueIntegrationCfgs {
|
|||
REDMINE?: RedmineCfg;
|
||||
AZURE_DEVOPS?: AzureDevOpsCfg;
|
||||
NEXTCLOUD_DECK?: NextcloudDeckCfg;
|
||||
PLAINSPACE?: PlainspaceCfg;
|
||||
}
|
||||
|
||||
export type IssueData =
|
||||
|
|
@ -131,6 +137,7 @@ export type IssueData =
|
|||
| RedmineIssue
|
||||
| AzureDevOpsIssue
|
||||
| NextcloudDeckIssue
|
||||
| PlainspaceIssue
|
||||
| PluginIssue;
|
||||
|
||||
export type IssueDataReduced =
|
||||
|
|
@ -142,6 +149,7 @@ export type IssueDataReduced =
|
|||
| RedmineIssue
|
||||
| AzureDevOpsIssueReduced
|
||||
| NextcloudDeckIssueReduced
|
||||
| PlainspaceIssue
|
||||
| PluginSearchResult;
|
||||
|
||||
export type IssueDataReducedMap = {
|
||||
|
|
@ -161,11 +169,13 @@ export type IssueDataReducedMap = {
|
|||
? AzureDevOpsIssueReduced
|
||||
: K extends 'NEXTCLOUD_DECK'
|
||||
? NextcloudDeckIssueReduced
|
||||
: K extends MigratedIssueProviderKey
|
||||
? PluginSearchResult
|
||||
: K extends PluginIssueProviderKey
|
||||
: K extends 'PLAINSPACE'
|
||||
? PlainspaceIssue
|
||||
: K extends MigratedIssueProviderKey
|
||||
? PluginSearchResult
|
||||
: never;
|
||||
: K extends PluginIssueProviderKey
|
||||
? PluginSearchResult
|
||||
: never;
|
||||
};
|
||||
|
||||
// TODO: add issue model to the IssueDataReducedMap
|
||||
|
|
@ -267,6 +277,10 @@ export interface IssueProviderNextcloudDeck extends IssueProviderBase, Nextcloud
|
|||
issueProviderKey: 'NEXTCLOUD_DECK';
|
||||
}
|
||||
|
||||
export interface IssueProviderPlainspace extends IssueProviderBase, PlainspaceCfg {
|
||||
issueProviderKey: 'PLAINSPACE';
|
||||
}
|
||||
|
||||
export interface IssueProviderPluginType extends IssueProviderBase {
|
||||
issueProviderKey: PluginIssueProviderKey | MigratedIssueProviderKey;
|
||||
pluginId: string;
|
||||
|
|
@ -286,6 +300,7 @@ export type IssueProvider =
|
|||
| IssueProviderLinear
|
||||
| IssueProviderAzureDevOps
|
||||
| IssueProviderNextcloudDeck
|
||||
| IssueProviderPlainspace
|
||||
| IssueProviderPluginType;
|
||||
|
||||
export type IssueProviderTypeMap<T extends IssueProviderKey> = T extends 'JIRA'
|
||||
|
|
@ -312,8 +327,10 @@ export type IssueProviderTypeMap<T extends IssueProviderKey> = T extends 'JIRA'
|
|||
? IssueProviderAzureDevOps
|
||||
: T extends 'NEXTCLOUD_DECK'
|
||||
? IssueProviderNextcloudDeck
|
||||
: T extends PluginIssueProviderKey
|
||||
? IssueProviderPluginType
|
||||
: T extends MigratedIssueProviderKey
|
||||
: T extends 'PLAINSPACE'
|
||||
? IssueProviderPlainspace
|
||||
: T extends PluginIssueProviderKey
|
||||
? IssueProviderPluginType
|
||||
: never;
|
||||
: T extends MigratedIssueProviderKey
|
||||
? IssueProviderPluginType
|
||||
: never;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
REDMINE_TYPE,
|
||||
AZURE_DEVOPS_TYPE,
|
||||
NEXTCLOUD_DECK_TYPE,
|
||||
PLAINSPACE_TYPE,
|
||||
} from './issue.const';
|
||||
import { TaskService } from '../tasks/task.service';
|
||||
import { IssueTask, Task, TaskCopy } from '../tasks/task.model';
|
||||
|
|
@ -43,6 +44,7 @@ import { RedmineCommonInterfacesService } from './providers/redmine/redmine-comm
|
|||
// ClickUp is now a plugin — no built-in service needed
|
||||
import { AzureDevOpsCommonInterfacesService } from './providers/azure-devops/azure-devops-common-interfaces.service';
|
||||
import { NextcloudDeckCommonInterfacesService } from './providers/nextcloud-deck/nextcloud-deck-common-interfaces.service';
|
||||
import { PlainspaceCommonInterfacesService } from './providers/plainspace/plainspace-common-interfaces.service';
|
||||
import { SnackService } from '../../core/snack/snack.service';
|
||||
import { T } from '../../t.const';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
|
@ -80,6 +82,7 @@ export class IssueService {
|
|||
private _nextcloudDeckCommonInterfaceService = inject(
|
||||
NextcloudDeckCommonInterfacesService,
|
||||
);
|
||||
private _plainspaceCommonInterfaceService = inject(PlainspaceCommonInterfacesService);
|
||||
private _calendarCommonInterfaceService = inject(CalendarCommonInterfacesService);
|
||||
private _issueProviderService = inject(IssueProviderService);
|
||||
private _workContextService = inject(WorkContextService);
|
||||
|
|
@ -102,6 +105,7 @@ export class IssueService {
|
|||
[ICAL_TYPE]: this._calendarCommonInterfaceService,
|
||||
[AZURE_DEVOPS_TYPE]: this._azureDevOpsCommonInterfaceService,
|
||||
[NEXTCLOUD_DECK_TYPE]: this._nextcloudDeckCommonInterfaceService,
|
||||
[PLAINSPACE_TYPE]: this._plainspaceCommonInterfaceService,
|
||||
};
|
||||
|
||||
ISSUE_REFRESH_MAP: {
|
||||
|
|
@ -654,7 +658,7 @@ export class IssueService {
|
|||
const subTaskData = this._getAddTaskData(issueProviderKey, subtask);
|
||||
const { title: subTaskTitle, ...subTaskAdditional } = subTaskData;
|
||||
|
||||
await this._taskService.addSubTaskTo(parentTaskId, {
|
||||
this._taskService.addSubTaskTo(parentTaskId, {
|
||||
title: subTaskTitle,
|
||||
issueType: issueProviderKey,
|
||||
issueProviderId: issueProviderId,
|
||||
|
|
@ -703,7 +707,7 @@ export class IssueService {
|
|||
// sub-task (has a parentId), attach to its root parent so the new task
|
||||
// becomes a sibling of the parent rather than a grandchild.
|
||||
const effectiveParentId = parentTask.task.parentId || parentTask.task.id;
|
||||
const taskId = await this._taskService.addSubTaskTo(effectiveParentId, subTaskData);
|
||||
const taskId = this._taskService.addSubTaskTo(effectiveParentId, subTaskData);
|
||||
return { taskId, parentTaskId: effectiveParentId };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ export const getIssueProviderTooltip = (issueProvider: IssueProvider): string =>
|
|||
: undefined;
|
||||
case 'AZURE_DEVOPS':
|
||||
return issueProvider.project || undefined;
|
||||
case 'PLAINSPACE':
|
||||
return issueProvider.spaceId || undefined;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -139,5 +141,7 @@ export const getIssueProviderInitials = (
|
|||
return issueProvider.selectedBoardTitle?.substring(0, 2)?.toUpperCase();
|
||||
case 'AZURE_DEVOPS':
|
||||
return issueProvider.project?.substring(0, 2)?.toUpperCase() || 'AD';
|
||||
case 'PLAINSPACE':
|
||||
return issueProvider.spaceId?.substring(0, 2)?.toUpperCase() || 'PS';
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,189 @@
|
|||
import {
|
||||
HttpClientTestingModule,
|
||||
HttpTestingController,
|
||||
} from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { PlainspaceApiService } from './plainspace-api.service';
|
||||
import { PlainspaceCfg } from './plainspace.model';
|
||||
import { DEFAULT_PLAINSPACE_CFG } from './plainspace-cfg-form.const';
|
||||
|
||||
// Covers the real Plainspace integration API: PAT auth, the SPTask -> internal
|
||||
// PlainspaceIssue mapping, per-space scoping, claim/create, and fail-soft reads.
|
||||
describe('PlainspaceApiService', () => {
|
||||
let service: PlainspaceApiService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
const cfg: PlainspaceCfg = {
|
||||
...DEFAULT_PLAINSPACE_CFG,
|
||||
host: 'https://plainspace.org',
|
||||
spaceId: 'space-1',
|
||||
token: 'pat_test',
|
||||
};
|
||||
const BASE = 'https://plainspace.org/api/integration';
|
||||
|
||||
const spTask = (
|
||||
id: string,
|
||||
projectId: string,
|
||||
done = false,
|
||||
scheduledAt: string | null = null,
|
||||
isRecurring = false,
|
||||
): SPTaskLike => ({
|
||||
id,
|
||||
title: `Task ${id}`,
|
||||
done,
|
||||
projectId,
|
||||
projectName: 'P',
|
||||
projectSlug: 'p',
|
||||
listId: 'l',
|
||||
url: `https://plainspace.org/p/item/${id}`,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-02T00:00:00.000Z',
|
||||
scheduledAt,
|
||||
isRecurring,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [PlainspaceApiService],
|
||||
});
|
||||
service = TestBed.inject(PlainspaceApiService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('getMyTasks$ sends the PAT, keeps only this space, and maps to PlainspaceIssue', async () => {
|
||||
const p = firstValueFrom(service.getMyTasks$(cfg));
|
||||
const req = httpMock.expectOne(`${BASE}/tasks`);
|
||||
expect(req.request.headers.get('Authorization')).toBe('Bearer pat_test');
|
||||
req.flush({ tasks: [spTask('a', 'space-1', true), spTask('b', 'other')] });
|
||||
const tasks = await p;
|
||||
expect(tasks.map((t) => t.id)).toEqual(['a']);
|
||||
expect(tasks[0].isDone).toBe(true);
|
||||
expect(tasks[0].url).toBe('https://plainspace.org/p/item/a');
|
||||
});
|
||||
|
||||
it('getUnclaimedTasks$ fetches the claim pool and keeps only this space', async () => {
|
||||
const p = firstValueFrom(service.getUnclaimedTasks$(cfg));
|
||||
const req = httpMock.expectOne(`${BASE}/claimable-tasks`);
|
||||
req.flush({ tasks: [spTask('u1', 'space-1'), spTask('u2', 'other')] });
|
||||
expect((await p).map((t) => t.id)).toEqual(['u1']);
|
||||
});
|
||||
|
||||
it('getMyTasks$ matches the bound space by slug, not just the id', async () => {
|
||||
const slugCfg: PlainspaceCfg = { ...cfg, spaceId: 'my-slug' };
|
||||
const p = firstValueFrom(service.getMyTasks$(slugCfg));
|
||||
const req = httpMock.expectOne(`${BASE}/tasks`);
|
||||
req.flush({
|
||||
tasks: [
|
||||
{ ...spTask('a', 'uuid-1'), projectSlug: 'my-slug' },
|
||||
{ ...spTask('b', 'uuid-2'), projectSlug: 'other-slug' },
|
||||
],
|
||||
});
|
||||
expect((await p).map((t) => t.id)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('claimTask$ POSTs to the claim endpoint and maps the task', async () => {
|
||||
const p = firstValueFrom(service.claimTask$('u1', cfg));
|
||||
const req = httpMock.expectOne(`${BASE}/tasks/u1/claim`);
|
||||
expect(req.request.method).toBe('POST');
|
||||
req.flush({ task: spTask('u1', 'space-1') });
|
||||
expect((await p)?.id).toBe('u1');
|
||||
});
|
||||
|
||||
it('getById$ returns null on 404', async () => {
|
||||
const p = firstValueFrom(service.getById$('missing', cfg));
|
||||
httpMock
|
||||
.expectOne(`${BASE}/tasks/missing`)
|
||||
.flush({ error: 'Task not found' }, { status: 404, statusText: 'Not Found' });
|
||||
expect(await p).toBeNull();
|
||||
});
|
||||
|
||||
it('patchTask$ PATCHes the given fields and maps scheduledAt back', async () => {
|
||||
const p = firstValueFrom(
|
||||
service.patchTask$(
|
||||
'a',
|
||||
{ done: true, scheduledAt: '2026-01-02T09:00:00.000Z' },
|
||||
cfg,
|
||||
),
|
||||
);
|
||||
const req = httpMock.expectOne(`${BASE}/tasks/a`);
|
||||
expect(req.request.method).toBe('PATCH');
|
||||
expect(req.request.body).toEqual({
|
||||
done: true,
|
||||
scheduledAt: '2026-01-02T09:00:00.000Z',
|
||||
});
|
||||
req.flush({ task: spTask('a', 'space-1', true, '2026-01-02T09:00:00.000Z') });
|
||||
const issue = await p;
|
||||
expect(issue?.isDone).toBe(true);
|
||||
expect(issue?.scheduledAt).toBe('2026-01-02T09:00:00.000Z');
|
||||
});
|
||||
|
||||
it('getSpaces$ maps the account spaces from /me', async () => {
|
||||
const p = firstValueFrom(service.getSpaces$(cfg));
|
||||
const req = httpMock.expectOne(`${BASE}/me`);
|
||||
req.flush({
|
||||
email: 'a@b.c',
|
||||
projects: [
|
||||
{ id: 'p1', name: 'One', slug: 'one', memberDisplayName: 'Me', role: 'admin' },
|
||||
{ id: 'p2', name: 'Two', slug: 'two', memberDisplayName: 'Me', role: 'member' },
|
||||
],
|
||||
});
|
||||
expect(await p).toEqual([
|
||||
{ id: 'p1', name: 'One', slug: 'one' },
|
||||
{ id: 'p2', name: 'Two', slug: 'two' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('getSpaces$ returns null on error (so callers can tell error from empty)', async () => {
|
||||
const p = firstValueFrom(service.getSpaces$(cfg));
|
||||
httpMock
|
||||
.expectOne(`${BASE}/me`)
|
||||
.flush('boom', { status: 500, statusText: 'Server Error' });
|
||||
expect(await p).toBeNull();
|
||||
});
|
||||
|
||||
it('createSpace$ returns the new project id', async () => {
|
||||
const p = firstValueFrom(service.createSpace$('My Space', cfg));
|
||||
const req = httpMock.expectOne(`${BASE}/spaces`);
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual({ name: 'My Space' });
|
||||
req.flush({ project: { id: 'proj-new' }, url: 'x', memberId: 'm' });
|
||||
expect((await p).id).toBe('proj-new');
|
||||
});
|
||||
|
||||
it('searchIssues$ filters my tasks by title', async () => {
|
||||
const p = firstValueFrom(service.searchIssues$('task a', cfg));
|
||||
httpMock
|
||||
.expectOne(`${BASE}/tasks`)
|
||||
.flush({ tasks: [spTask('a', 'space-1'), spTask('b', 'space-1')] });
|
||||
const res = await p;
|
||||
expect(res.length).toBe(1);
|
||||
expect(res[0].issueType).toBe('PLAINSPACE');
|
||||
});
|
||||
|
||||
it('reads fail soft to [] on a network error', async () => {
|
||||
const p = firstValueFrom(service.getMyTasks$(cfg));
|
||||
httpMock
|
||||
.expectOne(`${BASE}/tasks`)
|
||||
.flush('boom', { status: 500, statusText: 'Server Error' });
|
||||
expect(await p).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
interface SPTaskLike {
|
||||
id: string;
|
||||
title: string;
|
||||
done: boolean;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
projectSlug: string;
|
||||
listId: string;
|
||||
url: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
scheduledAt: string | null;
|
||||
isRecurring: boolean;
|
||||
}
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { SearchResultItem } from '../../issue.model';
|
||||
import { PlainspaceCfg } from './plainspace.model';
|
||||
import { PlainspaceIssue } from './plainspace-issue.model';
|
||||
import { mapPlainspaceIssueToSearchResult } from './plainspace-issue-map.util';
|
||||
|
||||
/**
|
||||
* HTTP access to the real Plainspace integration API (plainspace.org /
|
||||
* `Johannesjo/spaces`). Every endpoint lives under `{host}/api/integration` and
|
||||
* is authorized with the provider's personal API token (`Authorization: Bearer
|
||||
* pat_…`). The server already scopes `/tasks` to the caller, so "mine" is decided
|
||||
* server-side — no client-side identity filtering is needed.
|
||||
*
|
||||
* The wire format (`SPTask`) is mapped to the provider-internal `PlainspaceIssue`
|
||||
* here, keeping the real contract isolated to this file. See
|
||||
* docs/plainspace-api-extension-plan.md for the endpoint contract.
|
||||
*
|
||||
* Reads fail soft (empty list / null) so a Plainspace outage never blocks the SP
|
||||
* UI; `createSpace$` lets errors propagate so the share flow can report them.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlainspaceApiService {
|
||||
private _http = inject(HttpClient);
|
||||
|
||||
/** Verifies the token and returns the account's email + spaces, or null. */
|
||||
getMe$(cfg: PlainspaceCfg): Observable<SPMeResponse | null> {
|
||||
return this._http
|
||||
.get<SPMeResponse>(`${this._base(cfg)}/me`, { headers: this._headers(cfg) })
|
||||
.pipe(catchError(() => of(null)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The spaces (Plainspace projects) the token can access — used to let the user
|
||||
* link an existing space instead of creating a new one. Returns `null` when the
|
||||
* request fails (offline / invalid token) so callers can tell a genuine "no
|
||||
* spaces yet" (empty list) apart from an error.
|
||||
*/
|
||||
getSpaces$(cfg: PlainspaceCfg): Observable<PlainspaceSpace[] | null> {
|
||||
return this.getMe$(cfg).pipe(
|
||||
map((me) =>
|
||||
me ? me.projects.map((p) => ({ id: p.id, name: p.name, slug: p.slug })) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Tasks assigned to me in this provider's space — imported as SP tasks. */
|
||||
getMyTasks$(cfg: PlainspaceCfg): Observable<PlainspaceIssue[]> {
|
||||
return this._http
|
||||
.get<SPTasksResponse>(`${this._base(cfg)}/tasks`, { headers: this._headers(cfg) })
|
||||
.pipe(
|
||||
// /tasks spans all my spaces; keep only this provider's space.
|
||||
map((res) =>
|
||||
(Array.isArray(res?.tasks) ? res.tasks : [])
|
||||
.filter((t) => matchesSpace(t, cfg.spaceId))
|
||||
.map(mapSPTaskToIssue),
|
||||
),
|
||||
catchError(() => of([])),
|
||||
);
|
||||
}
|
||||
|
||||
/** Unclaimed (unassigned, not-done) tasks in this space — the claim pool. */
|
||||
getUnclaimedTasks$(cfg: PlainspaceCfg): Observable<PlainspaceIssue[]> {
|
||||
// Filter to the bound space client-side so `spaceId` works whether it holds
|
||||
// the project UUID or the slug (the server `?projectId=` param only accepts
|
||||
// the UUID).
|
||||
return this._http
|
||||
.get<SPTasksResponse>(`${this._base(cfg)}/claimable-tasks`, {
|
||||
headers: this._headers(cfg),
|
||||
})
|
||||
.pipe(
|
||||
map((res) =>
|
||||
(Array.isArray(res?.tasks) ? res.tasks : [])
|
||||
.filter((t) => matchesSpace(t, cfg.spaceId))
|
||||
.map(mapSPTaskToIssue),
|
||||
),
|
||||
catchError(() => of([])),
|
||||
);
|
||||
}
|
||||
|
||||
getById$(id: string, cfg: PlainspaceCfg): Observable<PlainspaceIssue | null> {
|
||||
return this._http
|
||||
.get<SPTaskResponse>(`${this._base(cfg)}/tasks/${encodeURIComponent(id)}`, {
|
||||
headers: this._headers(cfg),
|
||||
})
|
||||
.pipe(
|
||||
map((res) => mapSPTaskToIssue(res.task)),
|
||||
catchError(() => of(null)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-assigns ("claims") an unclaimed task. Returns the claimed task, or null
|
||||
* if it could not be claimed (already taken, offline, …).
|
||||
*/
|
||||
claimTask$(id: string, cfg: PlainspaceCfg): Observable<PlainspaceIssue | null> {
|
||||
return this._http
|
||||
.post<SPTaskResponse>(
|
||||
`${this._base(cfg)}/tasks/${encodeURIComponent(id)}/claim`,
|
||||
{},
|
||||
{ headers: this._headers(cfg) },
|
||||
)
|
||||
.pipe(
|
||||
map((res) => mapSPTaskToIssue(res.task)),
|
||||
catchError(() => of(null)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes a field change back to Plainspace — done state, title, and/or
|
||||
* scheduled time (`scheduledAt`) — in a single PATCH; null on failure.
|
||||
* `scheduledAt` is an ISO instant, or null to unschedule. Used by the
|
||||
* two-way-sync adapter.
|
||||
*/
|
||||
patchTask$(
|
||||
id: string,
|
||||
fields: { done?: boolean; title?: string; scheduledAt?: string | null },
|
||||
cfg: PlainspaceCfg,
|
||||
): Observable<PlainspaceIssue | null> {
|
||||
return this._http
|
||||
.patch<SPTaskResponse>(
|
||||
`${this._base(cfg)}/tasks/${encodeURIComponent(id)}`,
|
||||
fields,
|
||||
{ headers: this._headers(cfg) },
|
||||
)
|
||||
.pipe(
|
||||
map((res) => mapSPTaskToIssue(res.task)),
|
||||
catchError(() => of(null)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Creates a remote space and returns its id (used by the share flow). */
|
||||
createSpace$(title: string, cfg: PlainspaceCfg): Observable<{ id: string }> {
|
||||
return this._http
|
||||
.post<SPCreateSpaceResponse>(
|
||||
`${this._base(cfg)}/spaces`,
|
||||
{ name: title },
|
||||
{ headers: this._headers(cfg) },
|
||||
)
|
||||
.pipe(map((res) => ({ id: res.project.id })));
|
||||
}
|
||||
|
||||
searchIssues$(query: string, cfg: PlainspaceCfg): Observable<SearchResultItem[]> {
|
||||
const term = query.trim().toLowerCase();
|
||||
return this.getMyTasks$(cfg).pipe(
|
||||
map((issues) =>
|
||||
issues
|
||||
.filter((issue) => !term || issue.title.toLowerCase().includes(term))
|
||||
.map((issue) => mapPlainspaceIssueToSearchResult(issue)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private _base(cfg: PlainspaceCfg): string {
|
||||
return `${cfg.host}/api/integration`;
|
||||
}
|
||||
|
||||
private _headers(cfg: PlainspaceCfg): HttpHeaders {
|
||||
return new HttpHeaders({ Authorization: `Bearer ${cfg.token ?? ''}` });
|
||||
}
|
||||
}
|
||||
|
||||
/** A Plainspace space (project) the connected account can bind a provider to. */
|
||||
export interface PlainspaceSpace {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
/** The Plainspace integration task DTO (`GET /api/integration/tasks`). */
|
||||
interface SPTask {
|
||||
id: string;
|
||||
title: string;
|
||||
done: boolean;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
projectSlug: string;
|
||||
listId: string;
|
||||
url: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
// ISO instant the task is scheduled for, or null when unscheduled. For
|
||||
// recurring items this is the next occurrence (server-advanced). See
|
||||
// docs/plainspace-api-extension-plan.md §scheduling.
|
||||
scheduledAt: string | null;
|
||||
// Whether the task repeats in Plainspace (the cadence stays server-side).
|
||||
isRecurring: boolean;
|
||||
}
|
||||
|
||||
interface SPTaskResponse {
|
||||
task: SPTask;
|
||||
}
|
||||
|
||||
interface SPTasksResponse {
|
||||
tasks: SPTask[];
|
||||
}
|
||||
|
||||
interface SPCreateSpaceResponse {
|
||||
project: { id: string };
|
||||
}
|
||||
|
||||
interface SPMeResponse {
|
||||
email: string;
|
||||
projects: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
memberDisplayName: string;
|
||||
role: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
// `cfg.spaceId` may hold either the Plainspace project UUID or its slug (what
|
||||
// users see in the space URL, e.g. plainspace.org/<slug>/…), so match both.
|
||||
const matchesSpace = (t: SPTask, spaceId: string | null | undefined): boolean =>
|
||||
!spaceId || t.projectId === spaceId || t.projectSlug === spaceId;
|
||||
|
||||
const mapSPTaskToIssue = (t: SPTask): PlainspaceIssue => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
isDone: t.done,
|
||||
updatedAt: t.updatedAt,
|
||||
url: t.url,
|
||||
projectId: t.projectId,
|
||||
// Normalize to a canonical UTC ISO instant on read. The two-way-sync baseline
|
||||
// and push both compare `scheduledAt` by exact string, and the push side emits
|
||||
// `new Date(ms).toISOString()` — so an equivalent-but-differently-formatted
|
||||
// server value (offset vs Z, ms precision) would otherwise read as a remote
|
||||
// change and silently drop the user's reschedule.
|
||||
scheduledAt: t.scheduledAt ? new Date(t.scheduledAt).toISOString() : null,
|
||||
isRecurring: !!t.isRecurring,
|
||||
});
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { T } from '../../../../t.const';
|
||||
import {
|
||||
ConfigFormSection,
|
||||
LimitedFormlyFieldConfig,
|
||||
} from '../../../config/global-config.model';
|
||||
import { IssueProviderPlainspace } from '../../issue.model';
|
||||
import { ISSUE_PROVIDER_COMMON_FORM_FIELDS } from '../../common-issue-form-stuff.const';
|
||||
import { PlainspaceCfg } from './plainspace.model';
|
||||
|
||||
export const DEFAULT_PLAINSPACE_CFG: PlainspaceCfg = {
|
||||
isEnabled: false,
|
||||
host: 'https://plainspace.org',
|
||||
spaceId: null,
|
||||
token: null,
|
||||
isAutoPoll: true,
|
||||
// Tasks assigned to me auto-import into the bound project's backlog, and the
|
||||
// poll keeps them in sync — Plainspace is meant to feel automatic.
|
||||
isAutoAddToBacklog: true,
|
||||
};
|
||||
|
||||
export const PLAINSPACE_CONFIG_FORM: LimitedFormlyFieldConfig<IssueProviderPlainspace>[] =
|
||||
[
|
||||
{
|
||||
key: 'host',
|
||||
type: 'input',
|
||||
templateOptions: {
|
||||
label: T.PLAINSPACE.FORM.HOST,
|
||||
type: 'url',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'spaceId',
|
||||
type: 'input',
|
||||
templateOptions: {
|
||||
label: T.PLAINSPACE.FORM.SPACE_ID,
|
||||
required: false,
|
||||
description: T.PLAINSPACE.FORM.SPACE_ID_DESCRIPTION,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'token',
|
||||
type: 'input',
|
||||
templateOptions: {
|
||||
label: T.PLAINSPACE.FORM.TOKEN,
|
||||
type: 'password',
|
||||
required: true,
|
||||
description: T.PLAINSPACE.FORM.TOKEN_DESCRIPTION,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'collapsible',
|
||||
props: { label: T.G.ADVANCED_CFG },
|
||||
fieldGroup: [...ISSUE_PROVIDER_COMMON_FORM_FIELDS],
|
||||
},
|
||||
];
|
||||
|
||||
export const PLAINSPACE_CONFIG_FORM_SECTION: ConfigFormSection<IssueProviderPlainspace> =
|
||||
{
|
||||
title: T.PLAINSPACE.FORM_SECTION.TITLE,
|
||||
key: 'PLAINSPACE',
|
||||
items: PLAINSPACE_CONFIG_FORM,
|
||||
help: T.PLAINSPACE.FORM_SECTION.HELP,
|
||||
};
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { PlainspaceCommonInterfacesService } from './plainspace-common-interfaces.service';
|
||||
import { PlainspaceApiService } from './plainspace-api.service';
|
||||
import { PlainspaceIssue } from './plainspace-issue.model';
|
||||
import { IssueProviderService } from '../../issue-provider.service';
|
||||
import { Task } from '../../../tasks/task.model';
|
||||
|
||||
describe('PlainspaceCommonInterfacesService', () => {
|
||||
let service: PlainspaceCommonInterfacesService;
|
||||
let api: PlainspaceApiService;
|
||||
|
||||
const issue = (scheduledAt: string | null, isDone = false): PlainspaceIssue => ({
|
||||
id: 't1',
|
||||
title: 'Buy milk',
|
||||
isDone,
|
||||
updatedAt: '2026-01-02T00:00:00.000Z',
|
||||
url: 'https://plainspace.org/p/item/t1',
|
||||
projectId: 'space-1',
|
||||
scheduledAt,
|
||||
isRecurring: false,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
PlainspaceCommonInterfacesService,
|
||||
// extractSyncValues is pure, so the API stub is never called here.
|
||||
{ provide: PlainspaceApiService, useValue: {} },
|
||||
{ provide: IssueProviderService, useValue: {} },
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(PlainspaceCommonInterfacesService);
|
||||
api = TestBed.inject(PlainspaceApiService);
|
||||
});
|
||||
|
||||
// Without a seeded baseline, computePushDecisions skips every push as
|
||||
// 'no-baseline', so the done + scheduled-time write-back never fires.
|
||||
it('getAddTaskData seeds the two-way-sync baseline (done + scheduledAt)', () => {
|
||||
const data = service.getAddTaskData(issue('2026-01-02T09:00:00.000Z', true));
|
||||
expect(data.title).toBe('Buy milk');
|
||||
expect(data.isDone).toBe(true);
|
||||
expect(data.issueLastSyncedValues).toEqual({
|
||||
isDone: true,
|
||||
title: 'Buy milk',
|
||||
scheduledAt: '2026-01-02T09:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('getAddTaskData baseline carries a null scheduledAt for unscheduled tasks', () => {
|
||||
const data = service.getAddTaskData(issue(null));
|
||||
expect(data.issueLastSyncedValues).toEqual({
|
||||
isDone: false,
|
||||
title: 'Buy milk',
|
||||
scheduledAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('getAddTaskData imports scheduledAt as dueWithTime (schedule shows in the app)', () => {
|
||||
const iso = '2026-01-02T09:00:00.000Z';
|
||||
const data = service.getAddTaskData(issue(iso));
|
||||
expect(data.dueWithTime).toBe(new Date(iso).getTime());
|
||||
});
|
||||
|
||||
it('getAddTaskData leaves dueWithTime unset for unscheduled tasks', () => {
|
||||
const data = service.getAddTaskData(issue(null));
|
||||
expect('dueWithTime' in data).toBe(false);
|
||||
});
|
||||
|
||||
describe('getFreshDataForIssueTask (poll pulls scheduledAt → dueWithTime)', () => {
|
||||
const stubCfg = (): void => {
|
||||
spyOn(
|
||||
service as unknown as { _getCfgOnce$: (id: string) => unknown },
|
||||
'_getCfgOnce$',
|
||||
).and.returnValue(of({}));
|
||||
};
|
||||
const setRemote = (i: PlainspaceIssue): void => {
|
||||
(api as unknown as { getById$: () => unknown }).getById$ = () => of(i);
|
||||
};
|
||||
|
||||
it('pulls dueWithTime when the remote task changed', async () => {
|
||||
setRemote(issue('2026-01-02T09:00:00.000Z'));
|
||||
stubCfg();
|
||||
const task = { issueProviderId: 'p1', issueId: 't1', issueLastUpdated: 0 } as Task;
|
||||
const res = await service.getFreshDataForIssueTask(task);
|
||||
expect(res?.taskChanges.dueWithTime).toBe(
|
||||
new Date('2026-01-02T09:00:00.000Z').getTime(),
|
||||
);
|
||||
expect(res?.taskChanges.issueWasUpdated).toBe(true);
|
||||
});
|
||||
|
||||
it('clears dueWithTime when the remote task was unscheduled', async () => {
|
||||
setRemote(issue(null));
|
||||
stubCfg();
|
||||
const task = { issueProviderId: 'p1', issueId: 't1', issueLastUpdated: 0 } as Task;
|
||||
const res = await service.getFreshDataForIssueTask(task);
|
||||
expect(res?.taskChanges.dueWithTime).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns null when the remote task is unchanged', async () => {
|
||||
setRemote(issue('2026-01-02T09:00:00.000Z'));
|
||||
stubCfg();
|
||||
const task = {
|
||||
issueProviderId: 'p1',
|
||||
issueId: 't1',
|
||||
issueLastUpdated: new Date('2026-01-02T00:00:00.000Z').getTime(),
|
||||
} as Task;
|
||||
expect(await service.getFreshDataForIssueTask(task)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFreshDataForIssueTasks (bulk poll = one fetch per provider)', () => {
|
||||
it('fetches all tasks once via getMyTasks$, not one getById per task', async () => {
|
||||
spyOn(
|
||||
service as unknown as { _getCfgOnce$: (id: string) => unknown },
|
||||
'_getCfgOnce$',
|
||||
).and.returnValue(of({}));
|
||||
const t1 = { ...issue('2026-01-02T09:00:00.000Z'), id: 't1' };
|
||||
const t2 = { ...issue('2026-01-03T09:00:00.000Z'), id: 't2' };
|
||||
const getMyTasks$ = jasmine.createSpy('getMyTasks$').and.returnValue(of([t1, t2]));
|
||||
const getById$ = jasmine.createSpy('getById$');
|
||||
(api as unknown as { getMyTasks$: unknown }).getMyTasks$ = getMyTasks$;
|
||||
(api as unknown as { getById$: unknown }).getById$ = getById$;
|
||||
|
||||
const tasks = [
|
||||
{ issueProviderId: 'p1', issueId: 't1', issueLastUpdated: 0 },
|
||||
{ issueProviderId: 'p1', issueId: 't2', issueLastUpdated: 0 },
|
||||
] as Task[];
|
||||
const updates = await service.getFreshDataForIssueTasks(tasks);
|
||||
|
||||
expect(getMyTasks$).toHaveBeenCalledTimes(1);
|
||||
expect(getById$).not.toHaveBeenCalled();
|
||||
expect(updates.map((u) => u.task.issueId)).toEqual(['t1', 't2']);
|
||||
});
|
||||
|
||||
it('skips tasks that are no longer returned (e.g. unassigned from me)', async () => {
|
||||
spyOn(
|
||||
service as unknown as { _getCfgOnce$: (id: string) => unknown },
|
||||
'_getCfgOnce$',
|
||||
).and.returnValue(of({}));
|
||||
(api as unknown as { getMyTasks$: unknown }).getMyTasks$ = () =>
|
||||
of([{ ...issue('2026-01-02T09:00:00.000Z'), id: 't1' }]);
|
||||
const tasks = [
|
||||
{ issueProviderId: 'p1', issueId: 't1', issueLastUpdated: 0 },
|
||||
{ issueProviderId: 'p1', issueId: 'gone', issueLastUpdated: 0 },
|
||||
] as Task[];
|
||||
const updates = await service.getFreshDataForIssueTasks(tasks);
|
||||
expect(updates.map((u) => u.task.issueId)).toEqual(['t1']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
import { Injectable, inject } from '@angular/core';
|
||||
import { firstValueFrom, Observable } from 'rxjs';
|
||||
import { map, switchMap } from 'rxjs/operators';
|
||||
import { Task, TaskCopy } from '../../../tasks/task.model';
|
||||
import { BaseIssueProviderService } from '../../base/base-issue-provider.service';
|
||||
import { IssueData, IssueDataReduced, SearchResultItem } from '../../issue.model';
|
||||
import { PLAINSPACE_POLL_INTERVAL } from './plainspace.const';
|
||||
import { PlainspaceCfg } from './plainspace.model';
|
||||
import { PlainspaceApiService } from './plainspace-api.service';
|
||||
import { PlainspaceSyncAdapterService } from './plainspace-sync-adapter.service';
|
||||
import { PlainspaceIssue } from './plainspace-issue.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class PlainspaceCommonInterfacesService extends BaseIssueProviderService<PlainspaceCfg> {
|
||||
private readonly _plainspaceApiService = inject(PlainspaceApiService);
|
||||
private readonly _syncAdapter = inject(PlainspaceSyncAdapterService);
|
||||
|
||||
readonly providerKey = 'PLAINSPACE' as const;
|
||||
readonly pollInterval: number = PLAINSPACE_POLL_INTERVAL;
|
||||
|
||||
isEnabled(cfg: PlainspaceCfg): boolean {
|
||||
return !!cfg && cfg.isEnabled && !!cfg.host && !!cfg.spaceId && !!cfg.token;
|
||||
}
|
||||
|
||||
testConnection(cfg: PlainspaceCfg): Promise<boolean> {
|
||||
return firstValueFrom(
|
||||
this._plainspaceApiService.getMe$(cfg).pipe(map((res) => !!res)),
|
||||
).then((result) => result ?? false);
|
||||
}
|
||||
|
||||
issueLink(issueId: string | number, issueProviderId: string): Promise<string> {
|
||||
// The canonical link (`{origin}/{slug}/item/{id}`) comes from the task's own
|
||||
// `url`; fall back to the host root if the task can't be fetched (offline).
|
||||
return firstValueFrom(
|
||||
this._getCfgOnce$(issueProviderId).pipe(
|
||||
switchMap((cfg) =>
|
||||
this._plainspaceApiService
|
||||
.getById$(String(issueId), cfg)
|
||||
.pipe(map((issue) => issue?.url || `${cfg.host}`)),
|
||||
),
|
||||
),
|
||||
).then((result) => result ?? '');
|
||||
}
|
||||
|
||||
getAddTaskData(
|
||||
issue: PlainspaceIssue,
|
||||
): Partial<Readonly<TaskCopy>> & { title: string } {
|
||||
// Import Plainspace's `scheduledAt` as the SP task's scheduled time so it
|
||||
// shows in the app. A provider-supplied `dueWithTime` is routed by the import
|
||||
// pipeline through `addAndSchedule` (sets the time + a reminder + Today
|
||||
// membership). Poll updates keep it in sync via the override below.
|
||||
const dueWithTime = issue.scheduledAt
|
||||
? new Date(issue.scheduledAt).getTime()
|
||||
: undefined;
|
||||
return {
|
||||
title: issue.title,
|
||||
isDone: issue.isDone,
|
||||
issueWasUpdated: false,
|
||||
issueLastUpdated: new Date(issue.updatedAt).getTime(),
|
||||
...(dueWithTime ? { dueWithTime } : {}),
|
||||
// Seed the two-way-sync baseline (last-known remote values) so push-only
|
||||
// fields — done and scheduled time — can detect a change. Without it
|
||||
// computePushDecisions skips every push as 'no-baseline' and nothing is
|
||||
// ever written back. Mirrors the CalDAV provider.
|
||||
issueLastSyncedValues: this._syncAdapter.extractSyncValues(
|
||||
issue as unknown as Record<string, unknown>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Plainspace owns the schedule for shared tasks, so — unlike the base, which
|
||||
* drops `dueWithTime` on poll to protect user-set schedules — we pull
|
||||
* `scheduledAt` into `dueWithTime` here. This schedules already-imported tasks
|
||||
* once they next change remotely and keeps recurring items in sync as the
|
||||
* server advances `scheduledAt` to the next occurrence. User reschedules in SP
|
||||
* push back via the two-way-sync adapter, so the values stay consistent.
|
||||
* Mirrors the CalDAV provider's date-on-poll override.
|
||||
*/
|
||||
override async getFreshDataForIssueTask(task: Task): Promise<{
|
||||
taskChanges: Partial<Task>;
|
||||
issue: IssueData;
|
||||
issueTitle: string;
|
||||
} | null> {
|
||||
if (!task.issueProviderId || !task.issueId) {
|
||||
return null;
|
||||
}
|
||||
const cfg = await firstValueFrom(this._getCfgOnce$(task.issueProviderId));
|
||||
const issue = await firstValueFrom(
|
||||
this._plainspaceApiService.getById$(task.issueId, cfg),
|
||||
);
|
||||
return this._toFreshData(task, issue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll all of a provider's imported tasks at once. `GET /tasks` already returns
|
||||
* every task assigned to me in one call, so we fetch it once per provider and
|
||||
* diff locally — instead of the base's one `getById` HTTP request per task
|
||||
* (which is N redundant calls of the same data). A task that is no longer
|
||||
* assigned to me simply isn't in the response and is left untouched this cycle.
|
||||
*/
|
||||
override async getFreshDataForIssueTasks(
|
||||
tasks: Task[],
|
||||
): Promise<{ task: Task; taskChanges: Partial<Task>; issue: IssueData }[]> {
|
||||
const tasksByProviderId = new Map<string, Task[]>();
|
||||
for (const task of tasks) {
|
||||
if (!task.issueProviderId || !task.issueId) {
|
||||
continue;
|
||||
}
|
||||
const group = tasksByProviderId.get(task.issueProviderId) ?? [];
|
||||
group.push(task);
|
||||
tasksByProviderId.set(task.issueProviderId, group);
|
||||
}
|
||||
|
||||
const updates: { task: Task; taskChanges: Partial<Task>; issue: IssueData }[] = [];
|
||||
for (const [providerId, providerTasks] of tasksByProviderId) {
|
||||
const cfg = await firstValueFrom(this._getCfgOnce$(providerId));
|
||||
const issuesById = new Map(
|
||||
(await firstValueFrom(this._plainspaceApiService.getMyTasks$(cfg))).map(
|
||||
(issue) => [issue.id, issue] as const,
|
||||
),
|
||||
);
|
||||
for (const task of providerTasks) {
|
||||
const fresh = this._toFreshData(task, issuesById.get(task.issueId!) ?? null);
|
||||
if (fresh) {
|
||||
updates.push({ task, taskChanges: fresh.taskChanges, issue: fresh.issue });
|
||||
}
|
||||
}
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
|
||||
private _toFreshData(
|
||||
task: Task,
|
||||
issue: PlainspaceIssue | null,
|
||||
): { taskChanges: Partial<Task>; issue: IssueData; issueTitle: string } | null {
|
||||
if (!issue || new Date(issue.updatedAt).getTime() === task.issueLastUpdated) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
taskChanges: {
|
||||
...this.getAddTaskData(issue),
|
||||
// Explicit (incl. undefined to unschedule) — the base deletes this.
|
||||
dueWithTime: issue.scheduledAt
|
||||
? new Date(issue.scheduledAt).getTime()
|
||||
: undefined,
|
||||
issueWasUpdated: true,
|
||||
},
|
||||
issue: issue as unknown as IssueData,
|
||||
issueTitle: issue.title,
|
||||
};
|
||||
}
|
||||
|
||||
async getNewIssuesToAddToBacklog(
|
||||
issueProviderId: string,
|
||||
_allExistingIssueIds: string[],
|
||||
): Promise<IssueDataReduced[]> {
|
||||
const cfg = await firstValueFrom(this._getCfgOnce$(issueProviderId));
|
||||
// Only tasks assigned to me become SP tasks; unclaimed tasks are claimed
|
||||
// explicitly via the claim pool, never auto-imported.
|
||||
return await firstValueFrom(this._plainspaceApiService.getMyTasks$(cfg));
|
||||
}
|
||||
|
||||
protected _apiGetById$(
|
||||
id: string | number,
|
||||
cfg: PlainspaceCfg,
|
||||
): Observable<IssueData | null> {
|
||||
return this._plainspaceApiService.getById$(
|
||||
String(id),
|
||||
cfg,
|
||||
) as Observable<IssueData | null>;
|
||||
}
|
||||
|
||||
protected _apiSearchIssues$(
|
||||
searchTerm: string,
|
||||
cfg: PlainspaceCfg,
|
||||
): Observable<SearchResultItem[]> {
|
||||
return this._plainspaceApiService.searchIssues$(searchTerm, cfg);
|
||||
}
|
||||
|
||||
protected _formatIssueTitleForSnack(issue: IssueData): string {
|
||||
return (issue as PlainspaceIssue).title;
|
||||
}
|
||||
|
||||
protected _getIssueLastUpdated(issue: IssueData): number {
|
||||
return new Date((issue as PlainspaceIssue).updatedAt).getTime();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { T } from '../../../../t.const';
|
||||
import { IssueFieldType } from '../../issue-content/issue-content.model';
|
||||
import { PLAINSPACE_ISSUE_CONTENT_CONFIG } from './plainspace-issue-content.const';
|
||||
import { PlainspaceIssue } from './plainspace-issue.model';
|
||||
|
||||
const makeIssue = (over: Partial<PlainspaceIssue> = {}): PlainspaceIssue =>
|
||||
({
|
||||
id: 'ps-1',
|
||||
title: 'Shared task',
|
||||
isDone: false,
|
||||
updatedAt: '2026-06-18T00:00:00.000Z',
|
||||
url: 'https://plainspace.org/space/item/ps-1',
|
||||
projectId: 'space-1',
|
||||
scheduledAt: null,
|
||||
isRecurring: false,
|
||||
...over,
|
||||
}) as PlainspaceIssue;
|
||||
|
||||
describe('PLAINSPACE_ISSUE_CONTENT_CONFIG', () => {
|
||||
const recurringField = PLAINSPACE_ISSUE_CONTENT_CONFIG.fields.find(
|
||||
(f) => f.label === T.PLAINSPACE.RECURRING,
|
||||
);
|
||||
const linkField = PLAINSPACE_ISSUE_CONTENT_CONFIG.fields.find(
|
||||
(f) => f.label === T.PLAINSPACE.OPEN_IN_PLAINSPACE,
|
||||
);
|
||||
|
||||
// title/done/schedule are mirrored onto the SP task, so the panel must not echo
|
||||
// the title back as a redundant "Summary" row.
|
||||
it('does not re-show the title as a redundant Summary field', () => {
|
||||
expect(
|
||||
PLAINSPACE_ISSUE_CONTENT_CONFIG.fields.some(
|
||||
(f) => f.label === T.F.ISSUE.ISSUE_CONTENT.SUMMARY,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
describe('recurrence indicator', () => {
|
||||
it('renders the shared `repeat` icon via a custom field', () => {
|
||||
expect(recurringField?.type).toBe(IssueFieldType.CUSTOM);
|
||||
expect(recurringField?.customTemplate).toBe('plainspace-recurring');
|
||||
});
|
||||
|
||||
it('shows only when the task recurs in Plainspace', () => {
|
||||
expect(recurringField?.isVisible?.(makeIssue({ isRecurring: true }))).toBe(true);
|
||||
expect(recurringField?.isVisible?.(makeIssue({ isRecurring: false }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('open-in-Plainspace link', () => {
|
||||
it('links to the task url', () => {
|
||||
expect(linkField?.type).toBe(IssueFieldType.LINK);
|
||||
expect(linkField?.getLink?.(makeIssue({ url: 'https://x/y' }))).toBe('https://x/y');
|
||||
});
|
||||
|
||||
it('hides when there is no url', () => {
|
||||
expect(linkField?.isVisible?.(makeIssue({ url: 'https://x/y' }))).toBe(true);
|
||||
expect(linkField?.isVisible?.(makeIssue({ url: null }))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { T } from '../../../../t.const';
|
||||
import {
|
||||
IssueContentConfig,
|
||||
IssueFieldType,
|
||||
} from '../../issue-content/issue-content.model';
|
||||
import { IssueProviderKey } from '../../issue.model';
|
||||
import { PlainspaceIssue } from './plainspace-issue.model';
|
||||
|
||||
export const PLAINSPACE_ISSUE_CONTENT_CONFIG: IssueContentConfig<PlainspaceIssue> = {
|
||||
issueType: 'PLAINSPACE' as IssueProviderKey,
|
||||
// Title, done state and schedule are mirrored onto the SP task itself, so the
|
||||
// panel would only echo them. Surface just what the task doesn't already show:
|
||||
// whether it recurs in Plainspace, and a link back to open it there.
|
||||
fields: [
|
||||
{
|
||||
label: T.PLAINSPACE.RECURRING,
|
||||
// Render the same `repeat` mat-icon the claim pool uses (a plain text value
|
||||
// cell can't hold an icon and isn't translated).
|
||||
type: IssueFieldType.CUSTOM,
|
||||
customTemplate: 'plainspace-recurring',
|
||||
value: 'isRecurring',
|
||||
isVisible: (issue: PlainspaceIssue) => issue.isRecurring,
|
||||
},
|
||||
{
|
||||
label: T.PLAINSPACE.OPEN_IN_PLAINSPACE,
|
||||
type: IssueFieldType.LINK,
|
||||
value: (issue: PlainspaceIssue) => issue.title,
|
||||
getLink: (issue: PlainspaceIssue) => issue.url || '',
|
||||
isVisible: (issue: PlainspaceIssue) => !!issue.url,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { IssueDataReduced, IssueProviderKey, SearchResultItem } from '../../issue.model';
|
||||
import { PlainspaceIssue } from './plainspace-issue.model';
|
||||
|
||||
export const mapPlainspaceIssueToSearchResult = (
|
||||
issue: PlainspaceIssue,
|
||||
): SearchResultItem => {
|
||||
return {
|
||||
title: issue.title,
|
||||
titleHighlighted: issue.title,
|
||||
issueType: 'PLAINSPACE' as IssueProviderKey,
|
||||
issueData: issue as IssueDataReduced,
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* Internal Plainspace issue shape used across the SP provider.
|
||||
*
|
||||
* The real Plainspace integration API returns an `SPTask`
|
||||
* (`GET {host}/api/integration/tasks`, see docs/plainspace-api-extension-plan.md).
|
||||
* `PlainspaceApiService` maps that DTO to this shape, so the rest of the
|
||||
* provider depends on one stable interface and the wire format stays isolated to
|
||||
* the API service.
|
||||
*/
|
||||
export type PlainspaceIssue = Readonly<{
|
||||
id: string;
|
||||
title: string;
|
||||
isDone: boolean;
|
||||
/** ISO timestamp; used for poll-based update detection. */
|
||||
updatedAt: string;
|
||||
/** Absolute link to open the task in the Plainspace web UI. */
|
||||
url: string | null;
|
||||
/** Remote Plainspace project/space id — used to scope tasks to a provider. */
|
||||
projectId: string;
|
||||
/**
|
||||
* ISO instant the task is scheduled for (Plainspace `scheduledAt`), or null
|
||||
* when unscheduled. Maps to SP's `task.dueWithTime`. For recurring Plainspace
|
||||
* items this is the *next* occurrence — the server advances it, SP just tracks
|
||||
* it.
|
||||
*/
|
||||
scheduledAt: string | null;
|
||||
/**
|
||||
* Whether the task repeats in Plainspace. The cadence stays Plainspace-side;
|
||||
* SP only carries the yes/no to flag recurrence.
|
||||
*/
|
||||
isRecurring: boolean;
|
||||
}>;
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
import { Injectable, inject } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { IssueProviderActions } from '../../store/issue-provider.actions';
|
||||
import { IssueProviderPlainspace } from '../../issue.model';
|
||||
import { ISSUE_PROVIDER_DEFAULT_COMMON_CFG } from '../../issue.const';
|
||||
import { PlainspaceApiService } from './plainspace-api.service';
|
||||
import { PlainspaceCfg } from './plainspace.model';
|
||||
import { DEFAULT_PLAINSPACE_CFG } from './plainspace-cfg-form.const';
|
||||
import { SnackService } from '../../../../core/snack/snack.service';
|
||||
import { Log } from '../../../../core/log';
|
||||
import { T } from '../../../../t.const';
|
||||
import { PlainspaceAccountService } from '../../../plainspace/plainspace-account.service';
|
||||
import { PlainspaceConnectDialogComponent } from '../../../plainspace/connect-dialog/plainspace-connect-dialog.component';
|
||||
import {
|
||||
PlainspaceSpaceChoice,
|
||||
PlainspaceSpacePickerDialogComponent,
|
||||
} from '../../../plainspace/space-picker-dialog/plainspace-space-picker-dialog.component';
|
||||
|
||||
/**
|
||||
* Provisions Plainspace sharing for a project: ensures the user is signed in,
|
||||
* creates a remote space and registers a bound `PLAINSPACE` issue-provider
|
||||
* instance (so tasks assigned to me / unassigned auto-import to the project
|
||||
* backlog). Used by the "Share on Plainspace" toggle in the create-project
|
||||
* dialog.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlainspaceShareService {
|
||||
private _store = inject(Store);
|
||||
private _plainspaceApiService = inject(PlainspaceApiService);
|
||||
private _accountService = inject(PlainspaceAccountService);
|
||||
private _snackService = inject(SnackService);
|
||||
private _matDialog = inject(MatDialog);
|
||||
|
||||
/**
|
||||
* Self-contained (never rejects) so it is safe to fire-and-forget from the
|
||||
* create-project dialog. Prompts for sign-in if needed, then lets the user
|
||||
* create a new space or link an existing one (so tasks already assigned to
|
||||
* them import). On failure (or if the user cancels) it surfaces a snack /
|
||||
* returns null.
|
||||
*
|
||||
* @returns the bound space id, or null if sharing could not be provisioned.
|
||||
*/
|
||||
async shareProjectOnPlainspace(
|
||||
projectId: string,
|
||||
title: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
if (!(await this._ensureConnected())) {
|
||||
this._snackService.open({ type: 'ERROR', msg: T.PLAINSPACE.LOGIN_REQUIRED });
|
||||
return null;
|
||||
}
|
||||
|
||||
const choice = await this._chooseSpace();
|
||||
if (!choice) {
|
||||
// User cancelled the space picker — nothing to provision.
|
||||
return null;
|
||||
}
|
||||
|
||||
const account = this._accountService.account();
|
||||
const cfg: PlainspaceCfg = {
|
||||
...DEFAULT_PLAINSPACE_CFG,
|
||||
host: account?.host ?? DEFAULT_PLAINSPACE_CFG.host,
|
||||
token: account?.token ?? null,
|
||||
};
|
||||
|
||||
const spaceId =
|
||||
choice.action === 'create'
|
||||
? (await firstValueFrom(this._plainspaceApiService.createSpace$(title, cfg)))
|
||||
?.id
|
||||
: choice.spaceId;
|
||||
if (!spaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const issueProvider: IssueProviderPlainspace = {
|
||||
...ISSUE_PROVIDER_DEFAULT_COMMON_CFG,
|
||||
...DEFAULT_PLAINSPACE_CFG,
|
||||
id: nanoid(),
|
||||
issueProviderKey: 'PLAINSPACE',
|
||||
isEnabled: true,
|
||||
defaultProjectId: projectId,
|
||||
isAutoAddToBacklog: true,
|
||||
host: cfg.host,
|
||||
token: cfg.token,
|
||||
spaceId,
|
||||
};
|
||||
this._store.dispatch(IssueProviderActions.addIssueProvider({ issueProvider }));
|
||||
this._snackService.open({ type: 'SUCCESS', msg: T.PLAINSPACE.SHARE_SUCCESS });
|
||||
return spaceId;
|
||||
} catch {
|
||||
// Log ids only — never user content (project title).
|
||||
Log.err('Plainspace: failed to share project', { projectId });
|
||||
this._snackService.open({ type: 'ERROR', msg: T.PLAINSPACE.SHARE_FAILED });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the space picker (create new vs link existing). Returns the chosen
|
||||
* action, or null if the user cancelled.
|
||||
*/
|
||||
private async _chooseSpace(): Promise<PlainspaceSpaceChoice | null> {
|
||||
const choice = await firstValueFrom(
|
||||
this._matDialog.open(PlainspaceSpacePickerDialogComponent).afterClosed(),
|
||||
);
|
||||
return choice ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a Plainspace account is connected, opening the guided connect dialog
|
||||
* (link + step-by-step) if not. The dialog validates the pasted token against
|
||||
* the host and resolves to whether a connection was established.
|
||||
*/
|
||||
private async _ensureConnected(): Promise<boolean> {
|
||||
if (this._accountService.isLoggedIn()) {
|
||||
return true;
|
||||
}
|
||||
const connected = await firstValueFrom(
|
||||
this._matDialog
|
||||
.open(PlainspaceConnectDialogComponent, {
|
||||
data: { host: DEFAULT_PLAINSPACE_CFG.host },
|
||||
})
|
||||
.afterClosed(),
|
||||
);
|
||||
return connected === true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { PlainspaceSyncAdapterService } from './plainspace-sync-adapter.service';
|
||||
import { PlainspaceApiService } from './plainspace-api.service';
|
||||
import { PlainspaceCfg } from './plainspace.model';
|
||||
import { DEFAULT_PLAINSPACE_CFG } from './plainspace-cfg-form.const';
|
||||
|
||||
describe('PlainspaceSyncAdapterService', () => {
|
||||
let adapter: PlainspaceSyncAdapterService;
|
||||
let api: jasmine.SpyObj<PlainspaceApiService>;
|
||||
|
||||
const cfg: PlainspaceCfg = {
|
||||
...DEFAULT_PLAINSPACE_CFG,
|
||||
host: 'https://plainspace.org',
|
||||
spaceId: 'space-1',
|
||||
token: 'pat_x',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
api = jasmine.createSpyObj('PlainspaceApiService', ['getById$', 'patchTask$']);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
PlainspaceSyncAdapterService,
|
||||
{ provide: PlainspaceApiService, useValue: api },
|
||||
],
|
||||
});
|
||||
adapter = TestBed.inject(PlainspaceSyncAdapterService);
|
||||
});
|
||||
|
||||
it('maps isDone, title and dueWithTime, all push-only', () => {
|
||||
expect(adapter.getSyncConfig(cfg)).toEqual({
|
||||
isDone: 'pushOnly',
|
||||
title: 'pushOnly',
|
||||
dueWithTime: 'pushOnly',
|
||||
});
|
||||
const mappings = adapter.getFieldMappings();
|
||||
expect(mappings.map((m) => [m.taskField, m.issueField])).toEqual([
|
||||
['isDone', 'isDone'],
|
||||
['title', 'title'],
|
||||
['dueWithTime', 'scheduledAt'],
|
||||
]);
|
||||
expect(mappings.every((m) => m.defaultDirection === 'pushOnly')).toBe(true);
|
||||
});
|
||||
|
||||
it('dueWithTime <-> scheduledAt maps epoch-ms to ISO and back', () => {
|
||||
const m = adapter.getFieldMappings().find((x) => x.taskField === 'dueWithTime')!;
|
||||
const ms = Date.UTC(2026, 0, 2, 9, 0, 0);
|
||||
expect(m.toIssueValue(ms, { issueId: 't1' })).toBe('2026-01-02T09:00:00.000Z');
|
||||
expect(m.toTaskValue('2026-01-02T09:00:00.000Z', { issueId: 't1' })).toBe(ms);
|
||||
// unschedule / absent -> null / undefined
|
||||
expect(m.toIssueValue(undefined, { issueId: 't1' })).toBeNull();
|
||||
expect(m.toTaskValue(null, { issueId: 't1' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('pushChanges PATCHes the done state', async () => {
|
||||
api.patchTask$.and.returnValue(of(null));
|
||||
await adapter.pushChanges('t1', { isDone: true }, cfg);
|
||||
expect(api.patchTask$).toHaveBeenCalledWith('t1', { done: true }, cfg);
|
||||
});
|
||||
|
||||
it('pushChanges PATCHes a renamed title', async () => {
|
||||
api.patchTask$.and.returnValue(of(null));
|
||||
await adapter.pushChanges('t1', { title: 'New name' }, cfg);
|
||||
expect(api.patchTask$).toHaveBeenCalledWith('t1', { title: 'New name' }, cfg);
|
||||
});
|
||||
|
||||
it('pushChanges PATCHes scheduledAt, including null to unschedule', async () => {
|
||||
api.patchTask$.and.returnValue(of(null));
|
||||
await adapter.pushChanges('t1', { scheduledAt: '2026-01-02T09:00:00.000Z' }, cfg);
|
||||
expect(api.patchTask$).toHaveBeenCalledWith(
|
||||
't1',
|
||||
{ scheduledAt: '2026-01-02T09:00:00.000Z' },
|
||||
cfg,
|
||||
);
|
||||
api.patchTask$.calls.reset();
|
||||
await adapter.pushChanges('t1', { scheduledAt: null }, cfg);
|
||||
expect(api.patchTask$).toHaveBeenCalledWith('t1', { scheduledAt: null }, cfg);
|
||||
});
|
||||
|
||||
it('pushChanges collapses done + scheduledAt into a single PATCH', async () => {
|
||||
api.patchTask$.and.returnValue(of(null));
|
||||
await adapter.pushChanges(
|
||||
't1',
|
||||
{ isDone: true, scheduledAt: '2026-01-02T09:00:00.000Z' },
|
||||
cfg,
|
||||
);
|
||||
expect(api.patchTask$).toHaveBeenCalledTimes(1);
|
||||
expect(api.patchTask$).toHaveBeenCalledWith(
|
||||
't1',
|
||||
{ done: true, scheduledAt: '2026-01-02T09:00:00.000Z' },
|
||||
cfg,
|
||||
);
|
||||
});
|
||||
|
||||
it('pushChanges does nothing when no mapped field is in the changes', async () => {
|
||||
await adapter.pushChanges('t1', { notes: 'x' }, cfg);
|
||||
expect(api.patchTask$).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetchIssue returns the issue, or {} when it is missing', async () => {
|
||||
api.getById$.and.returnValue(
|
||||
of({
|
||||
id: 't1',
|
||||
title: 'T',
|
||||
isDone: true,
|
||||
updatedAt: '2026-01-02T00:00:00.000Z',
|
||||
url: 'u',
|
||||
projectId: 'space-1',
|
||||
scheduledAt: null,
|
||||
isRecurring: false,
|
||||
}),
|
||||
);
|
||||
expect(await adapter.fetchIssue('t1', cfg)).toEqual(
|
||||
jasmine.objectContaining({ isDone: true }),
|
||||
);
|
||||
|
||||
api.getById$.and.returnValue(of(null));
|
||||
expect(await adapter.fetchIssue('missing', cfg)).toEqual({});
|
||||
});
|
||||
|
||||
it('extractSyncValues exposes isDone, title and scheduledAt (baseline)', () => {
|
||||
expect(
|
||||
adapter.extractSyncValues({
|
||||
isDone: true,
|
||||
title: 'x',
|
||||
scheduledAt: '2026-01-02T09:00:00.000Z',
|
||||
url: 'ignored',
|
||||
}),
|
||||
).toEqual({ isDone: true, title: 'x', scheduledAt: '2026-01-02T09:00:00.000Z' });
|
||||
});
|
||||
|
||||
it('getIssueLastUpdated parses updatedAt, or 0 when absent', () => {
|
||||
expect(adapter.getIssueLastUpdated({ updatedAt: '2026-01-02T00:00:00.000Z' })).toBe(
|
||||
new Date('2026-01-02T00:00:00.000Z').getTime(),
|
||||
);
|
||||
expect(adapter.getIssueLastUpdated({})).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import { Injectable, inject } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { IssueSyncAdapter } from '../../two-way-sync/issue-sync-adapter.interface';
|
||||
import { FieldMapping, FieldSyncConfig } from '../../two-way-sync/issue-sync.model';
|
||||
import { PlainspaceCfg } from './plainspace.model';
|
||||
import { PlainspaceApiService } from './plainspace-api.service';
|
||||
|
||||
/**
|
||||
* Push fields, written via PATCH /tasks/:id:
|
||||
* - `isDone` → `done`
|
||||
* - `title` → `title` (SP rename → Plainspace item text)
|
||||
* - `dueWithTime` → `scheduledAt` (SP scheduled time → Plainspace). SP stores an
|
||||
* epoch-ms number; Plainspace wants an ISO instant, or null to unschedule.
|
||||
* Plainspace's own reminder sweep then fires it for the team.
|
||||
*
|
||||
* Direction is `pushOnly` for all three: the reverse direction (Plainspace → SP)
|
||||
* is handled by issue-update polling (getFreshDataForIssueTask applies
|
||||
* getAddTaskData, which already carries title/isDone/scheduledAt), not this
|
||||
* adapter.
|
||||
*
|
||||
* `dueDay` (date-only scheduling, no time) is intentionally NOT mapped: Plainspace
|
||||
* `scheduledAt` always carries a time, so mapping a day-only task would fabricate
|
||||
* a time-of-day. There is no separate day field on Plainspace to clear, so no
|
||||
* `mutuallyExclusive` entry is needed.
|
||||
*/
|
||||
const PLAINSPACE_FIELD_MAPPINGS: FieldMapping[] = [
|
||||
{
|
||||
taskField: 'isDone',
|
||||
issueField: 'isDone',
|
||||
defaultDirection: 'pushOnly',
|
||||
toIssueValue: (taskValue: unknown): boolean => !!taskValue,
|
||||
toTaskValue: (issueValue: unknown): boolean => !!issueValue,
|
||||
},
|
||||
{
|
||||
taskField: 'title',
|
||||
issueField: 'title',
|
||||
defaultDirection: 'pushOnly',
|
||||
toIssueValue: (taskValue: unknown): string => (taskValue as string) ?? '',
|
||||
toTaskValue: (issueValue: unknown): string => (issueValue as string) ?? '',
|
||||
},
|
||||
{
|
||||
taskField: 'dueWithTime',
|
||||
issueField: 'scheduledAt',
|
||||
defaultDirection: 'pushOnly',
|
||||
toIssueValue: (taskValue: unknown): string | null =>
|
||||
typeof taskValue === 'number' ? new Date(taskValue).toISOString() : null,
|
||||
toTaskValue: (issueValue: unknown): number | undefined =>
|
||||
typeof issueValue === 'string' ? new Date(issueValue).getTime() : undefined,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Two-way sync adapter for Plainspace: pushes a task's done state and scheduled
|
||||
* time back to Plainspace when it is completed/reopened or (re)scheduled in Super
|
||||
* Productivity. Registered for the `PLAINSPACE` issue type in
|
||||
* IssueTwoWaySyncEffects.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlainspaceSyncAdapterService implements IssueSyncAdapter<PlainspaceCfg> {
|
||||
private readonly _api = inject(PlainspaceApiService);
|
||||
|
||||
getFieldMappings(): FieldMapping[] {
|
||||
return PLAINSPACE_FIELD_MAPPINGS;
|
||||
}
|
||||
|
||||
getSyncConfig(_cfg: PlainspaceCfg): FieldSyncConfig {
|
||||
return { isDone: 'pushOnly', title: 'pushOnly', dueWithTime: 'pushOnly' };
|
||||
}
|
||||
|
||||
async fetchIssue(
|
||||
issueId: string,
|
||||
cfg: PlainspaceCfg,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const issue = await firstValueFrom(this._api.getById$(issueId, cfg));
|
||||
return (issue ?? {}) as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async pushChanges(
|
||||
issueId: string,
|
||||
changes: Record<string, unknown>,
|
||||
cfg: PlainspaceCfg,
|
||||
): Promise<void> {
|
||||
// `changes` is keyed by issue field (toPush from the effect). Collapse done
|
||||
// and scheduled-time changes into a single PATCH.
|
||||
const fields: { done?: boolean; title?: string; scheduledAt?: string | null } = {};
|
||||
if ('isDone' in changes) {
|
||||
fields.done = !!changes['isDone'];
|
||||
}
|
||||
if ('title' in changes) {
|
||||
fields.title = (changes['title'] ?? '') as string;
|
||||
}
|
||||
if ('scheduledAt' in changes) {
|
||||
fields.scheduledAt = (changes['scheduledAt'] ?? null) as string | null;
|
||||
}
|
||||
if (Object.keys(fields).length === 0) {
|
||||
return;
|
||||
}
|
||||
await firstValueFrom(this._api.patchTask$(issueId, fields, cfg));
|
||||
}
|
||||
|
||||
extractSyncValues(issue: Record<string, unknown>): Record<string, unknown> {
|
||||
// Every push field needs a baseline here, else computePushDecisions skips it
|
||||
// as 'no-baseline' and nothing ever pushes.
|
||||
return {
|
||||
isDone: issue['isDone'],
|
||||
title: issue['title'],
|
||||
scheduledAt: issue['scheduledAt'],
|
||||
};
|
||||
}
|
||||
|
||||
getIssueLastUpdated(issue: Record<string, unknown>): number {
|
||||
const updatedAt = issue['updatedAt'];
|
||||
return updatedAt ? new Date(updatedAt as string).getTime() : 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export {
|
||||
PLAINSPACE_CONFIG_FORM_SECTION,
|
||||
PLAINSPACE_CONFIG_FORM,
|
||||
DEFAULT_PLAINSPACE_CFG,
|
||||
} from './plainspace-cfg-form.const';
|
||||
|
||||
export const PLAINSPACE_POLL_INTERVAL = 5 * 60 * 1000;
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { BaseIssueProviderCfg } from '../../issue.model';
|
||||
|
||||
/**
|
||||
* Per-instance config for the Plainspace (plainspace.org / `Johannesjo/spaces`)
|
||||
* issue provider. One instance is bound to one SP project (via the provider's
|
||||
* `defaultProjectId`) and one remote Plainspace space (`spaceId`).
|
||||
*
|
||||
* `token` is a Plainspace personal API token (PAT, `pat_…`) created in the
|
||||
* Plainspace web UI (Space settings → API tokens). It authorizes every call to
|
||||
* `{host}/api/integration/*`. Stored per provider like other issue providers'
|
||||
* secrets; a single PAT is valid across all of the user's spaces.
|
||||
*/
|
||||
export interface PlainspaceCfg extends BaseIssueProviderCfg {
|
||||
host: string | null;
|
||||
spaceId: string | null;
|
||||
token?: string | null;
|
||||
isAutoPoll?: boolean;
|
||||
isAutoAddToBacklog?: boolean;
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { IssueProviderService } from '../issue-provider.service';
|
|||
import { IssueSyncAdapterRegistryService } from './issue-sync-adapter-registry.service';
|
||||
import { IssueSyncAdapterResolverService } from './issue-sync-adapter-resolver.service';
|
||||
import { CaldavSyncAdapterService } from '../providers/caldav/caldav-sync-adapter.service';
|
||||
import { PlainspaceSyncAdapterService } from '../providers/plainspace/plainspace-sync-adapter.service';
|
||||
import { SnackService } from '../../../core/snack/snack.service';
|
||||
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
|
||||
import { PlannerActions } from '../../planner/store/planner.actions';
|
||||
|
|
@ -125,6 +126,14 @@ describe('IssueTwoWaySyncEffects', () => {
|
|||
'extractSyncValues',
|
||||
]);
|
||||
|
||||
const plainspaceSpy = jasmine.createSpyObj('PlainspaceSyncAdapterService', [
|
||||
'getFieldMappings',
|
||||
'getSyncConfig',
|
||||
'fetchIssue',
|
||||
'pushChanges',
|
||||
'extractSyncValues',
|
||||
]);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
IssueTwoWaySyncEffects,
|
||||
|
|
@ -136,6 +145,7 @@ describe('IssueTwoWaySyncEffects', () => {
|
|||
{ provide: TaskService, useValue: taskServiceSpy },
|
||||
{ provide: IssueProviderService, useValue: issueProviderServiceSpy },
|
||||
{ provide: CaldavSyncAdapterService, useValue: caldavSpy },
|
||||
{ provide: PlainspaceSyncAdapterService, useValue: plainspaceSpy },
|
||||
{ provide: SnackService, useValue: snackServiceSpy },
|
||||
{
|
||||
provide: IssueSyncAdapterResolverService,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { IssueProvider, IssueProviderKey } from '../issue.model';
|
|||
import { IssueLog } from '../../../core/log';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { CaldavSyncAdapterService } from '../providers/caldav/caldav-sync-adapter.service';
|
||||
import { PlainspaceSyncAdapterService } from '../providers/plainspace/plainspace-sync-adapter.service';
|
||||
import { SnackService } from '../../../core/snack/snack.service';
|
||||
import {
|
||||
DeletedTaskIssueSidecarService,
|
||||
|
|
@ -130,6 +131,8 @@ export class IssueTwoWaySyncEffects {
|
|||
constructor() {
|
||||
const caldavAdapter = inject(CaldavSyncAdapterService);
|
||||
this._adapterRegistry.register('CALDAV', caldavAdapter);
|
||||
const plainspaceAdapter = inject(PlainspaceSyncAdapterService);
|
||||
this._adapterRegistry.register('PLAINSPACE', plainspaceAdapter);
|
||||
}
|
||||
|
||||
pushFieldsOnTaskUpdate$: Observable<unknown> = createEffect(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
@if (tasks().length) {
|
||||
@for (task of tasks(); track task.id) {
|
||||
<div class="claim-task">
|
||||
<span class="title">{{ task.title }}</span>
|
||||
@if (task.isRecurring) {
|
||||
<mat-icon
|
||||
class="recurring-icon"
|
||||
[matTooltip]="T.PLAINSPACE.RECURRING | translate"
|
||||
>repeat</mat-icon
|
||||
>
|
||||
}
|
||||
@if (task.url) {
|
||||
<a
|
||||
class="open-link"
|
||||
mat-icon-button
|
||||
[href]="task.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
[matTooltip]="T.PLAINSPACE.OPEN_IN_PLAINSPACE | translate"
|
||||
>
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
</a>
|
||||
}
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
[disabled]="claimingIds().has(task.id)"
|
||||
(click)="claim(task)"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ T.PLAINSPACE.CLAIM | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="no-tasks">{{ T.PLAINSPACE.NO_UNCLAIMED | translate }}</div>
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
:host {
|
||||
display: block;
|
||||
padding: 0 var(--s);
|
||||
}
|
||||
|
||||
.claim-task {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s);
|
||||
padding: var(--s-half) var(--s);
|
||||
border-bottom: 1px solid var(--extra-border-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.open-link {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.recurring-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
opacity: 0.6;
|
||||
color: var(--text-color-muted);
|
||||
}
|
||||
|
||||
.no-tasks {
|
||||
padding: var(--s);
|
||||
color: var(--text-color-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { ChangeDetectionStrategy, Component, inject, input, signal } from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { T } from '../../../t.const';
|
||||
import { PlainspaceSharedTask } from '../plainspace-shared-task.model';
|
||||
import { PlainspaceClaimPoolService } from '../plainspace-claim-pool.service';
|
||||
|
||||
/**
|
||||
* Read-only list of unclaimed Plainspace tasks for a shared project, each with a
|
||||
* "Claim" action. Claiming assigns the task to me in Plainspace and imports it
|
||||
* as a first-class SP task; the claimed task then leaves the pool.
|
||||
*
|
||||
* Deliberately a lightweight, standalone component (not the hot-path
|
||||
* `TaskComponent`): these are foreign tasks until claimed and must never be
|
||||
* edited/scheduled or written into the SP task store. See
|
||||
* docs/plainspace-integration-plan.md.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'plainspace-claim-pool',
|
||||
templateUrl: './claim-pool.component.html',
|
||||
styleUrls: ['./claim-pool.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MatIcon, MatButton, MatIconButton, MatTooltip, TranslatePipe],
|
||||
})
|
||||
export class PlainspaceClaimPoolComponent {
|
||||
readonly T = T;
|
||||
readonly tasks = input<PlainspaceSharedTask[]>([]);
|
||||
readonly projectId = input<string>('');
|
||||
|
||||
private _claimPoolService = inject(PlainspaceClaimPoolService);
|
||||
readonly claimingIds = signal<ReadonlySet<string>>(new Set());
|
||||
|
||||
async claim(task: PlainspaceSharedTask): Promise<void> {
|
||||
const projectId = this.projectId();
|
||||
if (!projectId || this.claimingIds().has(task.id)) {
|
||||
return;
|
||||
}
|
||||
this._setClaiming(task.id, true);
|
||||
try {
|
||||
await this._claimPoolService.claim(projectId, task.id);
|
||||
} finally {
|
||||
this._setClaiming(task.id, false);
|
||||
}
|
||||
}
|
||||
|
||||
private _setClaiming(id: string, isClaiming: boolean): void {
|
||||
this.claimingIds.update((set) => {
|
||||
const next = new Set(set);
|
||||
if (isClaiming) {
|
||||
next.add(id);
|
||||
} else {
|
||||
next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
<h2 mat-dialog-title>{{ T.PLAINSPACE.CONNECT.TITLE | translate }}</h2>
|
||||
|
||||
<mat-dialog-content>
|
||||
<p class="intro">{{ T.PLAINSPACE.CONNECT.INTRO | translate }}</p>
|
||||
|
||||
<ol class="steps">
|
||||
<li>{{ T.PLAINSPACE.CONNECT.STEP_1 | translate }}</li>
|
||||
<li>{{ T.PLAINSPACE.CONNECT.STEP_2 | translate }}</li>
|
||||
<li>{{ T.PLAINSPACE.CONNECT.STEP_3 | translate }}</li>
|
||||
<li>{{ T.PLAINSPACE.CONNECT.STEP_4 | translate }}</li>
|
||||
</ol>
|
||||
|
||||
<p class="email-hint">{{ T.PLAINSPACE.CONNECT.EMAIL_HINT | translate }}</p>
|
||||
|
||||
<a
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
class="open-link"
|
||||
[href]="host"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
{{ T.PLAINSPACE.CONNECT.OPEN_LINK | translate }}
|
||||
</a>
|
||||
|
||||
<mat-form-field class="token-field">
|
||||
<mat-label>{{ T.PLAINSPACE.CONNECT.TOKEN_LABEL | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
[(ngModel)]="token"
|
||||
[placeholder]="T.PLAINSPACE.CONNECT.TOKEN_PLACEHOLDER | translate"
|
||||
(keydown.enter)="connect()"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
@if (hasError()) {
|
||||
<p class="error">{{ T.PLAINSPACE.CONNECT.INVALID | translate }}</p>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button
|
||||
mat-button
|
||||
[disabled]="isConnecting()"
|
||||
(click)="cancel()"
|
||||
>
|
||||
{{ T.G.CANCEL | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
[disabled]="!token.trim() || isConnecting()"
|
||||
(click)="connect()"
|
||||
>
|
||||
{{
|
||||
(isConnecting() ? T.PLAINSPACE.CONNECT.CONNECTING : T.PLAINSPACE.CONNECT.CONNECT)
|
||||
| translate
|
||||
}}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
:host {
|
||||
display: block;
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.steps {
|
||||
margin: 0 0 12px;
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
|
||||
.steps li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.email-hint {
|
||||
margin: 0 0 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-color-muted);
|
||||
}
|
||||
|
||||
.open-link {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.token-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 4px 0 0;
|
||||
color: var(--c-warn);
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { PlainspaceConnectDialogComponent } from './plainspace-connect-dialog.component';
|
||||
import { PlainspaceAccountService } from '../plainspace-account.service';
|
||||
|
||||
describe('PlainspaceConnectDialogComponent', () => {
|
||||
let component: PlainspaceConnectDialogComponent;
|
||||
let fixture: ComponentFixture<PlainspaceConnectDialogComponent>;
|
||||
let dialogRef: jasmine.SpyObj<MatDialogRef<PlainspaceConnectDialogComponent, boolean>>;
|
||||
let accountService: jasmine.SpyObj<PlainspaceAccountService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
dialogRef = jasmine.createSpyObj('MatDialogRef', ['close']);
|
||||
accountService = jasmine.createSpyObj('PlainspaceAccountService', ['connect']);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
PlainspaceConnectDialogComponent,
|
||||
NoopAnimationsModule,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{ provide: MatDialogRef, useValue: dialogRef },
|
||||
{ provide: MAT_DIALOG_DATA, useValue: { host: 'https://plainspace.org' } },
|
||||
{ provide: PlainspaceAccountService, useValue: accountService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PlainspaceConnectDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('renders and uses the provided host', () => {
|
||||
expect(component).toBeTruthy();
|
||||
expect(component.host).toBe('https://plainspace.org');
|
||||
});
|
||||
|
||||
it('does nothing when the token is blank', async () => {
|
||||
component.token = ' ';
|
||||
await component.connect();
|
||||
expect(accountService.connect).not.toHaveBeenCalled();
|
||||
expect(dialogRef.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('connects with the trimmed token and closes with true on success', async () => {
|
||||
accountService.connect.and.resolveTo(true);
|
||||
component.token = ' pat_abc ';
|
||||
await component.connect();
|
||||
expect(accountService.connect).toHaveBeenCalledWith(
|
||||
'pat_abc',
|
||||
'https://plainspace.org',
|
||||
);
|
||||
expect(dialogRef.close).toHaveBeenCalledWith(true);
|
||||
expect(component.hasError()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows an error and stays open on an invalid token', async () => {
|
||||
accountService.connect.and.resolveTo(false);
|
||||
component.token = 'bad';
|
||||
await component.connect();
|
||||
expect(component.hasError()).toBe(true);
|
||||
expect(component.isConnecting()).toBe(false);
|
||||
expect(dialogRef.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancel closes with false', () => {
|
||||
component.cancel();
|
||||
expect(dialogRef.close).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogActions,
|
||||
MatDialogContent,
|
||||
MatDialogRef,
|
||||
MatDialogTitle,
|
||||
} from '@angular/material/dialog';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatFormField, MatLabel } from '@angular/material/form-field';
|
||||
import { MatInput } from '@angular/material/input';
|
||||
import { MatAnchor, MatButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { T } from '../../../t.const';
|
||||
import { PlainspaceAccountService } from '../plainspace-account.service';
|
||||
|
||||
export interface PlainspaceConnectDialogData {
|
||||
host?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guided "Connect to Plainspace" dialog: shows where to create a personal API
|
||||
* token (link + step-by-step), takes the pasted token and validates it against
|
||||
* the host before closing. Resolves to `true` once connected, `false` if the
|
||||
* user cancels. Replaces the bare single-line token prompt.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'plainspace-connect-dialog',
|
||||
templateUrl: './plainspace-connect-dialog.component.html',
|
||||
styleUrls: ['./plainspace-connect-dialog.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
FormsModule,
|
||||
MatFormField,
|
||||
MatLabel,
|
||||
MatInput,
|
||||
MatButton,
|
||||
MatAnchor,
|
||||
MatIcon,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class PlainspaceConnectDialogComponent {
|
||||
private _dialogRef =
|
||||
inject<MatDialogRef<PlainspaceConnectDialogComponent, boolean>>(MatDialogRef);
|
||||
private _accountService = inject(PlainspaceAccountService);
|
||||
private _data = inject<PlainspaceConnectDialogData | null>(MAT_DIALOG_DATA, {
|
||||
optional: true,
|
||||
});
|
||||
|
||||
readonly T = T;
|
||||
readonly host = this._data?.host || 'https://plainspace.org';
|
||||
token = '';
|
||||
readonly isConnecting = signal(false);
|
||||
readonly hasError = signal(false);
|
||||
|
||||
async connect(): Promise<void> {
|
||||
const token = this.token.trim();
|
||||
if (!token || this.isConnecting()) {
|
||||
return;
|
||||
}
|
||||
this.isConnecting.set(true);
|
||||
this.hasError.set(false);
|
||||
const ok = await this._accountService.connect(token, this.host);
|
||||
if (ok) {
|
||||
this._dialogRef.close(true);
|
||||
} else {
|
||||
this.hasError.set(true);
|
||||
this.isConnecting.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this._dialogRef.close(false);
|
||||
}
|
||||
}
|
||||
12
src/app/features/plainspace/plainspace-account.model.ts
Normal file
12
src/app/features/plainspace/plainspace-account.model.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* The connected Plainspace account: the personal API token (PAT) plus the host
|
||||
* it was validated against, and the email it belongs to. Stored local-only (per
|
||||
* device), never synced. Used by the share-on-create flow to provision a space
|
||||
* before any provider exists, and to avoid re-entering the token.
|
||||
*/
|
||||
export interface PlainspaceAccount {
|
||||
host: string;
|
||||
/** Personal API token (`pat_…`). */
|
||||
token: string;
|
||||
email: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import {
|
||||
HttpClientTestingModule,
|
||||
HttpTestingController,
|
||||
} from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { PlainspaceAccountService } from './plainspace-account.service';
|
||||
import { LS } from '../../core/persistence/storage-keys.const';
|
||||
|
||||
describe('PlainspaceAccountService', () => {
|
||||
let service: PlainspaceAccountService;
|
||||
let httpMock: HttpTestingController;
|
||||
const ME_URL = 'https://plainspace.org/api/integration/me';
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(LS.PLAINSPACE_ACCOUNT);
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [PlainspaceAccountService],
|
||||
});
|
||||
service = TestBed.inject(PlainspaceAccountService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.removeItem(LS.PLAINSPACE_ACCOUNT);
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('starts logged out', () => {
|
||||
expect(service.isLoggedIn()).toBe(false);
|
||||
expect(service.token()).toBeNull();
|
||||
});
|
||||
|
||||
it('connect validates the token via /me, stores it, and persists', async () => {
|
||||
const p = service.connect('pat_x');
|
||||
const req = httpMock.expectOne(ME_URL);
|
||||
expect(req.request.headers.get('Authorization')).toBe('Bearer pat_x');
|
||||
req.flush({ email: 'me@example.com', projects: [] });
|
||||
|
||||
expect(await p).toBe(true);
|
||||
expect(service.isLoggedIn()).toBe(true);
|
||||
expect(service.token()).toBe('pat_x');
|
||||
expect(service.account()?.email).toBe('me@example.com');
|
||||
expect(localStorage.getItem(LS.PLAINSPACE_ACCOUNT)).toContain('pat_x');
|
||||
});
|
||||
|
||||
it('connect returns false and stays logged out on an invalid token', async () => {
|
||||
const p = service.connect('bad');
|
||||
httpMock
|
||||
.expectOne(ME_URL)
|
||||
.flush({ error: 'nope' }, { status: 401, statusText: 'Unauthorized' });
|
||||
|
||||
expect(await p).toBe(false);
|
||||
expect(service.isLoggedIn()).toBe(false);
|
||||
expect(localStorage.getItem(LS.PLAINSPACE_ACCOUNT)).toBeNull();
|
||||
});
|
||||
|
||||
it('logout clears the account and storage', async () => {
|
||||
const p = service.connect('pat_x');
|
||||
httpMock.expectOne(ME_URL).flush({ email: 'me@example.com', projects: [] });
|
||||
await p;
|
||||
|
||||
service.logout();
|
||||
expect(service.isLoggedIn()).toBe(false);
|
||||
expect(service.token()).toBeNull();
|
||||
expect(localStorage.getItem(LS.PLAINSPACE_ACCOUNT)).toBeNull();
|
||||
});
|
||||
});
|
||||
65
src/app/features/plainspace/plainspace-account.service.ts
Normal file
65
src/app/features/plainspace/plainspace-account.service.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { LS } from '../../core/persistence/storage-keys.const';
|
||||
import { Log } from '../../core/log';
|
||||
import { PlainspaceAccount } from './plainspace-account.model';
|
||||
import { PlainspaceApiService } from '../issue/providers/plainspace/plainspace-api.service';
|
||||
import { DEFAULT_PLAINSPACE_CFG } from '../issue/providers/plainspace/plainspace-cfg-form.const';
|
||||
|
||||
const DEFAULT_HOST = 'https://plainspace.org';
|
||||
|
||||
/**
|
||||
* Holds the connected Plainspace account (a personal API token + host) and
|
||||
* exposes it as signals. Persisted to localStorage (local-only, never synced —
|
||||
* a PAT is per device). Used by the share-on-create flow, which needs a token
|
||||
* before any provider/space exists.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlainspaceAccountService {
|
||||
private readonly _api = inject(PlainspaceApiService);
|
||||
private readonly _account = signal<PlainspaceAccount | null>(this._load());
|
||||
|
||||
readonly account = this._account.asReadonly();
|
||||
readonly isLoggedIn = computed(() => !!this._account());
|
||||
readonly token = computed(() => this._account()?.token ?? null);
|
||||
readonly host = computed(() => this._account()?.host ?? null);
|
||||
|
||||
/**
|
||||
* Validates a PAT against the host (`GET /api/integration/me`) and, on
|
||||
* success, stores it. Returns whether the token was accepted.
|
||||
*/
|
||||
async connect(token: string, host: string = DEFAULT_HOST): Promise<boolean> {
|
||||
const me = await firstValueFrom(
|
||||
this._api.getMe$({ ...DEFAULT_PLAINSPACE_CFG, host, token }),
|
||||
);
|
||||
if (!me) {
|
||||
return false;
|
||||
}
|
||||
const account: PlainspaceAccount = { host, token, email: me.email };
|
||||
this._account.set(account);
|
||||
this._save(account);
|
||||
return true;
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this._account.set(null);
|
||||
localStorage.removeItem(LS.PLAINSPACE_ACCOUNT);
|
||||
}
|
||||
|
||||
private _load(): PlainspaceAccount | null {
|
||||
const raw = localStorage.getItem(LS.PLAINSPACE_ACCOUNT);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as PlainspaceAccount;
|
||||
} catch {
|
||||
Log.err('Plainspace: failed to parse stored account');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private _save(account: PlainspaceAccount): void {
|
||||
localStorage.setItem(LS.PLAINSPACE_ACCOUNT, JSON.stringify(account));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { MockStore, provideMockStore } from '@ngrx/store/testing';
|
||||
import { firstValueFrom, of } from 'rxjs';
|
||||
import { PlainspaceClaimPoolService } from './plainspace-claim-pool.service';
|
||||
import { PlainspaceApiService } from '../issue/providers/plainspace/plainspace-api.service';
|
||||
import { PlainspaceIssue } from '../issue/providers/plainspace/plainspace-issue.model';
|
||||
import { selectEnabledIssueProviders } from '../issue/store/issue-provider.selectors';
|
||||
import { IssueService } from '../issue/issue.service';
|
||||
import { IssueProviderPlainspace } from '../issue/issue.model';
|
||||
import { SnackService } from '../../core/snack/snack.service';
|
||||
|
||||
const PLAINSPACE_PROVIDER = {
|
||||
id: 'p1',
|
||||
issueProviderKey: 'PLAINSPACE',
|
||||
isEnabled: true,
|
||||
defaultProjectId: 'proj-1',
|
||||
host: 'https://plainspace.org',
|
||||
spaceId: 'space-1',
|
||||
token: 'pat_x',
|
||||
} as IssueProviderPlainspace;
|
||||
|
||||
const issue = (id: string): PlainspaceIssue => ({
|
||||
id,
|
||||
title: id,
|
||||
isDone: false,
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
url: `https://plainspace.org/p/item/${id}`,
|
||||
projectId: 'space-1',
|
||||
scheduledAt: null,
|
||||
isRecurring: false,
|
||||
});
|
||||
|
||||
describe('PlainspaceClaimPoolService', () => {
|
||||
let service: PlainspaceClaimPoolService;
|
||||
let store: MockStore;
|
||||
let apiSpy: jasmine.SpyObj<PlainspaceApiService>;
|
||||
let addTaskFromIssueSpy: jasmine.Spy;
|
||||
let snackSpy: jasmine.SpyObj<SnackService>;
|
||||
|
||||
beforeEach(() => {
|
||||
apiSpy = jasmine.createSpyObj<PlainspaceApiService>('PlainspaceApiService', [
|
||||
'getUnclaimedTasks$',
|
||||
'claimTask$',
|
||||
]);
|
||||
apiSpy.getUnclaimedTasks$.and.returnValue(of([issue('ps-102'), issue('ps-105')]));
|
||||
apiSpy.claimTask$.and.returnValue(of(issue('ps-102')));
|
||||
addTaskFromIssueSpy = jasmine.createSpy('addTaskFromIssue').and.resolveTo('t1');
|
||||
snackSpy = jasmine.createSpyObj<SnackService>('SnackService', ['open']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
PlainspaceClaimPoolService,
|
||||
provideMockStore(),
|
||||
{ provide: PlainspaceApiService, useValue: apiSpy },
|
||||
{ provide: IssueService, useValue: { addTaskFromIssue: addTaskFromIssueSpy } },
|
||||
{ provide: SnackService, useValue: snackSpy },
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(PlainspaceClaimPoolService);
|
||||
store = TestBed.inject(MockStore);
|
||||
store.overrideSelector(selectEnabledIssueProviders, [PLAINSPACE_PROVIDER]);
|
||||
store.refreshState();
|
||||
});
|
||||
|
||||
it('returns the unclaimed tasks for a shared project', async () => {
|
||||
const unclaimed = await firstValueFrom(service.unclaimedTasksForProject$('proj-1'));
|
||||
expect(unclaimed.map((t) => t.id)).toEqual(['ps-102', 'ps-105']);
|
||||
});
|
||||
|
||||
it('returns empty when the project has no bound Plainspace provider', async () => {
|
||||
const none = await firstValueFrom(service.unclaimedTasksForProject$('other-proj'));
|
||||
expect(none).toEqual([]);
|
||||
});
|
||||
|
||||
it('claim assigns the task and imports it as an SP task', async () => {
|
||||
await service.claim('proj-1', 'ps-102');
|
||||
|
||||
expect(apiSpy.claimTask$).toHaveBeenCalledWith('ps-102', PLAINSPACE_PROVIDER);
|
||||
expect(addTaskFromIssueSpy).toHaveBeenCalledTimes(1);
|
||||
const arg = addTaskFromIssueSpy.calls.mostRecent().args[0];
|
||||
expect(arg.issueProviderKey).toBe('PLAINSPACE');
|
||||
expect(arg.issueDataReduced.id).toBe('ps-102');
|
||||
});
|
||||
|
||||
it('shows a success snack on a successful claim', async () => {
|
||||
await service.claim('proj-1', 'ps-102');
|
||||
expect(snackSpy.open).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ type: 'SUCCESS' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows an error snack and does not import when the claim fails', async () => {
|
||||
apiSpy.claimTask$.and.returnValue(of(null));
|
||||
await service.claim('proj-1', 'ps-102');
|
||||
expect(addTaskFromIssueSpy).not.toHaveBeenCalled();
|
||||
expect(snackSpy.open).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ type: 'ERROR' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
109
src/app/features/plainspace/plainspace-claim-pool.service.ts
Normal file
109
src/app/features/plainspace/plainspace-claim-pool.service.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { Injectable, inject } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Observable, Subject, combineLatest, firstValueFrom, of } from 'rxjs';
|
||||
import { distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators';
|
||||
import { selectEnabledIssueProviders } from '../issue/store/issue-provider.selectors';
|
||||
import { IssueProvider, IssueProviderPlainspace } from '../issue/issue.model';
|
||||
import { PlainspaceApiService } from '../issue/providers/plainspace/plainspace-api.service';
|
||||
import { IssueService } from '../issue/issue.service';
|
||||
import { SnackService } from '../../core/snack/snack.service';
|
||||
import { T } from '../../t.const';
|
||||
import { PlainspaceSharedTask } from './plainspace-shared-task.model';
|
||||
|
||||
/**
|
||||
* The "claim pool": unclaimed (unassigned) Plainspace tasks for a shared
|
||||
* project, shown read-only. Claiming assigns the task to the signed-in user in
|
||||
* Plainspace and imports it as a first-class SP task — the only way unclaimed
|
||||
* work enters SP (docs §7). A project is "shared" when it has a bound, enabled
|
||||
* `PLAINSPACE` provider (`defaultProjectId === projectId`).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlainspaceClaimPoolService {
|
||||
private _store = inject(Store);
|
||||
private _plainspaceApiService = inject(PlainspaceApiService);
|
||||
private _issueService = inject(IssueService);
|
||||
private _snackService = inject(SnackService);
|
||||
|
||||
// Pings the pool to re-fetch after a claim so the claimed task leaves the pool.
|
||||
private readonly _refresh$ = new Subject<void>();
|
||||
|
||||
unclaimedTasksForProject$(projectId: string): Observable<PlainspaceSharedTask[]> {
|
||||
// Editing any issue provider re-emits the list; collapse to this project's
|
||||
// bound provider and only re-fetch when its identity actually changes...
|
||||
const provider$ = this._store.select(selectEnabledIssueProviders).pipe(
|
||||
map((providers) => this._findProvider(providers, projectId)),
|
||||
distinctUntilChanged(
|
||||
(a, b) => a?.id === b?.id && a?.token === b?.token && a?.spaceId === b?.spaceId,
|
||||
),
|
||||
);
|
||||
// ...or on an explicit refresh (after a claim), so the claimed task leaves
|
||||
// the pool even though the provider itself is unchanged.
|
||||
return combineLatest([provider$, this._refresh$.pipe(startWith(undefined))]).pipe(
|
||||
switchMap(([provider]) =>
|
||||
provider ? this._unclaimedForProvider$(provider) : of([]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claims an unclaimed task: assigns it to me in Plainspace, then imports it as
|
||||
* an SP task (added to the project backlog).
|
||||
*/
|
||||
async claim(projectId: string, taskId: string): Promise<void> {
|
||||
const providers = await firstValueFrom(
|
||||
this._store.select(selectEnabledIssueProviders),
|
||||
);
|
||||
const provider = this._findProvider(providers, projectId);
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
const claimed = await firstValueFrom(
|
||||
this._plainspaceApiService.claimTask$(taskId, provider),
|
||||
);
|
||||
if (claimed) {
|
||||
await this._issueService.addTaskFromIssue({
|
||||
issueDataReduced: claimed,
|
||||
issueProviderId: provider.id,
|
||||
issueProviderKey: 'PLAINSPACE',
|
||||
isAddToBacklog: true,
|
||||
});
|
||||
this._snackService.open({ type: 'SUCCESS', msg: T.PLAINSPACE.CLAIM_SUCCESS });
|
||||
} else {
|
||||
// `claimTask$` fails soft to null — most often the race where someone else
|
||||
// claimed first (or an outage). One friendly message covers both; the
|
||||
// refresh below drops a now-claimed task from the pool.
|
||||
// shortcut: generic message — distinguish 409 "taken" from offline once the
|
||||
// server contract pins the status code.
|
||||
this._snackService.open({ type: 'ERROR', msg: T.PLAINSPACE.CLAIM_FAILED });
|
||||
}
|
||||
this._refresh$.next();
|
||||
}
|
||||
|
||||
private _findProvider(
|
||||
providers: IssueProvider[],
|
||||
projectId: string,
|
||||
): IssueProviderPlainspace | undefined {
|
||||
return providers.find(
|
||||
(p): p is IssueProviderPlainspace =>
|
||||
p.issueProviderKey === 'PLAINSPACE' && p.defaultProjectId === projectId,
|
||||
);
|
||||
}
|
||||
|
||||
private _unclaimedForProvider$(
|
||||
provider: IssueProviderPlainspace,
|
||||
): Observable<PlainspaceSharedTask[]> {
|
||||
return this._plainspaceApiService.getUnclaimedTasks$(provider).pipe(
|
||||
map((issues) =>
|
||||
issues.map(
|
||||
(issue): PlainspaceSharedTask => ({
|
||||
id: issue.id,
|
||||
title: issue.title,
|
||||
isDone: issue.isDone,
|
||||
url: issue.url,
|
||||
isRecurring: issue.isRecurring,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
17
src/app/features/plainspace/plainspace-shared-task.model.ts
Normal file
17
src/app/features/plainspace/plainspace-shared-task.model.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* The view-model for an unclaimed Plainspace task shown in the claim pool.
|
||||
*
|
||||
* These are deliberately NOT Super Productivity `Task`s: unclaimed tasks are
|
||||
* shown read-only and never enter the SP task store / op-log sync until claimed.
|
||||
* Mapped from `PlainspaceIssue` (the API shape) in `PlainspaceClaimPoolService`.
|
||||
*/
|
||||
export interface PlainspaceSharedTask {
|
||||
id: string;
|
||||
title: string;
|
||||
isDone: boolean;
|
||||
/** Absolute link to open the task in the Plainspace web UI. */
|
||||
url?: string | null;
|
||||
/** Repeats in Plainspace — flagged in the pool so claiming a recurring
|
||||
* commitment is visible up front. The cadence stays Plainspace-side. */
|
||||
isRecurring: boolean;
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<h2 mat-dialog-title>{{ T.PLAINSPACE.SPACE_PICKER.TITLE | translate }}</h2>
|
||||
|
||||
<mat-dialog-content>
|
||||
@if (isLoading()) {
|
||||
<p>{{ T.PLAINSPACE.SPACE_PICKER.LOADING | translate }}</p>
|
||||
} @else if (hasError()) {
|
||||
<p class="error">{{ T.PLAINSPACE.SPACE_PICKER.ERROR | translate }}</p>
|
||||
} @else if (spaces().length) {
|
||||
<p class="intro">{{ T.PLAINSPACE.SPACE_PICKER.INTRO | translate }}</p>
|
||||
|
||||
<mat-form-field class="space-field">
|
||||
<mat-label>{{ T.PLAINSPACE.SPACE_PICKER.SELECT_LABEL | translate }}</mat-label>
|
||||
<mat-select [(ngModel)]="selectedSpaceId">
|
||||
@for (space of spaces(); track space.id) {
|
||||
<mat-option [value]="space.id">{{ space.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
} @else {
|
||||
<p>{{ T.PLAINSPACE.SPACE_PICKER.NONE | translate }}</p>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button
|
||||
mat-button
|
||||
(click)="cancel()"
|
||||
>
|
||||
{{ T.G.CANCEL | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
[disabled]="hasError()"
|
||||
(click)="createNew()"
|
||||
>
|
||||
{{ T.PLAINSPACE.SPACE_PICKER.CREATE_NEW | translate }}
|
||||
</button>
|
||||
@if (spaces().length) {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
[disabled]="!selectedSpaceId"
|
||||
(click)="link()"
|
||||
>
|
||||
{{ T.PLAINSPACE.SPACE_PICKER.LINK | translate }}
|
||||
</button>
|
||||
}
|
||||
</mat-dialog-actions>
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
:host {
|
||||
display: block;
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.space-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 0;
|
||||
color: var(--c-warn);
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatDialogRef } from '@angular/material/dialog';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { PlainspaceSpacePickerDialogComponent } from './plainspace-space-picker-dialog.component';
|
||||
import { PlainspaceAccountService } from '../plainspace-account.service';
|
||||
import { PlainspaceApiService } from '../../issue/providers/plainspace/plainspace-api.service';
|
||||
|
||||
const SPACES = [
|
||||
{ id: 's1', name: 'Space One', slug: 'one' },
|
||||
{ id: 's2', name: 'Space Two', slug: 'two' },
|
||||
];
|
||||
const ACCOUNT = { host: 'https://plainspace.org', token: 'pat_abc', email: 'a@b.c' };
|
||||
|
||||
describe('PlainspaceSpacePickerDialogComponent', () => {
|
||||
let component: PlainspaceSpacePickerDialogComponent;
|
||||
let fixture: ComponentFixture<PlainspaceSpacePickerDialogComponent>;
|
||||
let dialogRef: jasmine.SpyObj<MatDialogRef<PlainspaceSpacePickerDialogComponent>>;
|
||||
let accountStub: { account: jasmine.Spy };
|
||||
let apiStub: { getSpaces$: jasmine.Spy };
|
||||
|
||||
beforeEach(async () => {
|
||||
dialogRef = jasmine.createSpyObj('MatDialogRef', ['close']);
|
||||
accountStub = { account: jasmine.createSpy('account').and.returnValue(ACCOUNT) };
|
||||
apiStub = { getSpaces$: jasmine.createSpy('getSpaces$').and.returnValue(of(SPACES)) };
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
PlainspaceSpacePickerDialogComponent,
|
||||
NoopAnimationsModule,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{ provide: MatDialogRef, useValue: dialogRef },
|
||||
{ provide: PlainspaceAccountService, useValue: accountStub },
|
||||
{ provide: PlainspaceApiService, useValue: apiStub },
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
const createComponent = (): void => {
|
||||
fixture = TestBed.createComponent(PlainspaceSpacePickerDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
it('loads the spaces and preselects the first', async () => {
|
||||
createComponent();
|
||||
await fixture.whenStable();
|
||||
expect(apiStub.getSpaces$).toHaveBeenCalled();
|
||||
expect(component.spaces()).toEqual(SPACES);
|
||||
expect(component.selectedSpaceId).toBe('s1');
|
||||
expect(component.isLoading()).toBe(false);
|
||||
});
|
||||
|
||||
it('link() closes with the selected existing space', async () => {
|
||||
createComponent();
|
||||
await fixture.whenStable();
|
||||
component.selectedSpaceId = 's2';
|
||||
component.link();
|
||||
expect(dialogRef.close).toHaveBeenCalledWith({ action: 'link', spaceId: 's2' });
|
||||
});
|
||||
|
||||
it('link() does nothing without a selection', async () => {
|
||||
apiStub.getSpaces$.and.returnValue(of([]));
|
||||
createComponent();
|
||||
await fixture.whenStable();
|
||||
component.selectedSpaceId = null;
|
||||
component.link();
|
||||
expect(dialogRef.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('createNew() closes with the create action', () => {
|
||||
createComponent();
|
||||
component.createNew();
|
||||
expect(dialogRef.close).toHaveBeenCalledWith({ action: 'create' });
|
||||
});
|
||||
|
||||
it('cancel() closes with no result', () => {
|
||||
createComponent();
|
||||
component.cancel();
|
||||
expect(dialogRef.close).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('shows the error state (not empty) when the spaces request fails', async () => {
|
||||
apiStub.getSpaces$.and.returnValue(of(null));
|
||||
createComponent();
|
||||
await fixture.whenStable();
|
||||
expect(component.hasError()).toBe(true);
|
||||
expect(component.isLoading()).toBe(false);
|
||||
expect(component.spaces()).toEqual([]);
|
||||
});
|
||||
|
||||
it('closes immediately when no account is connected', async () => {
|
||||
accountStub.account.and.returnValue(null);
|
||||
createComponent();
|
||||
await fixture.whenStable();
|
||||
expect(apiStub.getSpaces$).not.toHaveBeenCalled();
|
||||
expect(dialogRef.close).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import {
|
||||
MatDialogActions,
|
||||
MatDialogContent,
|
||||
MatDialogRef,
|
||||
MatDialogTitle,
|
||||
} from '@angular/material/dialog';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatFormField, MatLabel } from '@angular/material/form-field';
|
||||
import { MatSelect } from '@angular/material/select';
|
||||
import { MatOption } from '@angular/material/core';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { T } from '../../../t.const';
|
||||
import { PlainspaceAccountService } from '../plainspace-account.service';
|
||||
import {
|
||||
PlainspaceApiService,
|
||||
PlainspaceSpace,
|
||||
} from '../../issue/providers/plainspace/plainspace-api.service';
|
||||
import { DEFAULT_PLAINSPACE_CFG } from '../../issue/providers/plainspace/plainspace-cfg-form.const';
|
||||
|
||||
/** What the user chose: link one of their existing spaces, or create a new one. */
|
||||
export type PlainspaceSpaceChoice =
|
||||
| { action: 'create' }
|
||||
| { action: 'link'; spaceId: string };
|
||||
|
||||
/**
|
||||
* Lets the user link an existing Plainspace space (so the tasks already assigned
|
||||
* to them import) instead of always creating a new one. Assumes a connected
|
||||
* account (the share flow ensures that first); resolves to the chosen action, or
|
||||
* `undefined` if cancelled.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'plainspace-space-picker-dialog',
|
||||
templateUrl: './plainspace-space-picker-dialog.component.html',
|
||||
styleUrls: ['./plainspace-space-picker-dialog.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
FormsModule,
|
||||
MatFormField,
|
||||
MatLabel,
|
||||
MatSelect,
|
||||
MatOption,
|
||||
MatButton,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class PlainspaceSpacePickerDialogComponent {
|
||||
private _dialogRef =
|
||||
inject<MatDialogRef<PlainspaceSpacePickerDialogComponent, PlainspaceSpaceChoice>>(
|
||||
MatDialogRef,
|
||||
);
|
||||
private _accountService = inject(PlainspaceAccountService);
|
||||
private _api = inject(PlainspaceApiService);
|
||||
|
||||
readonly T = T;
|
||||
readonly spaces = signal<PlainspaceSpace[]>([]);
|
||||
readonly isLoading = signal(true);
|
||||
readonly hasError = signal(false);
|
||||
selectedSpaceId: string | null = null;
|
||||
|
||||
constructor() {
|
||||
void this._loadSpaces();
|
||||
}
|
||||
|
||||
link(): void {
|
||||
if (!this.selectedSpaceId) {
|
||||
return;
|
||||
}
|
||||
this._dialogRef.close({ action: 'link', spaceId: this.selectedSpaceId });
|
||||
}
|
||||
|
||||
createNew(): void {
|
||||
this._dialogRef.close({ action: 'create' });
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this._dialogRef.close();
|
||||
}
|
||||
|
||||
private async _loadSpaces(): Promise<void> {
|
||||
const account = this._accountService.account();
|
||||
if (!account) {
|
||||
// Should not happen — the share flow connects first — but bail safely.
|
||||
this._dialogRef.close();
|
||||
return;
|
||||
}
|
||||
const spaces = await firstValueFrom(
|
||||
this._api.getSpaces$({
|
||||
...DEFAULT_PLAINSPACE_CFG,
|
||||
host: account.host,
|
||||
token: account.token,
|
||||
}),
|
||||
);
|
||||
// null = the request failed (vs an empty list = genuinely no spaces yet).
|
||||
if (spaces === null) {
|
||||
this.hasError.set(true);
|
||||
} else {
|
||||
this.spaces.set(spaces);
|
||||
this.selectedSpaceId = spaces[0]?.id ?? null;
|
||||
}
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,10 @@ import { FormlyFieldConfig, FormlyFormOptions, FormlyModule } from '@ngx-formly/
|
|||
import { ProjectService } from '../../project.service';
|
||||
import { DEFAULT_PROJECT } from '../../project.const';
|
||||
import { JiraCfg } from '../../../issue/providers/jira/jira.model';
|
||||
import { CREATE_PROJECT_BASIC_CONFIG_FORM_CONFIG } from '../../project-form-cfg.const';
|
||||
import {
|
||||
CREATE_PROJECT_BASIC_CONFIG_FORM_CONFIG,
|
||||
CreateProjectFormModel,
|
||||
} from '../../project-form-cfg.const';
|
||||
import { SS } from '../../../../core/persistence/storage-keys.const';
|
||||
import { Subscription } from 'rxjs';
|
||||
import {
|
||||
|
|
@ -36,6 +39,7 @@ import { removeDebounceFromFormItems } from '../../../../util/remove-debounce-fr
|
|||
import { MatButton } from '@angular/material/button';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { Log } from '../../../../core/log';
|
||||
import { PlainspaceShareService } from '../../../issue/providers/plainspace/plainspace-share.service';
|
||||
|
||||
@Component({
|
||||
selector: 'dialog-create-project',
|
||||
|
|
@ -56,6 +60,7 @@ import { Log } from '../../../../core/log';
|
|||
export class DialogCreateProjectComponent implements OnInit, OnDestroy {
|
||||
private _project = inject<Project>(MAT_DIALOG_DATA);
|
||||
private _projectService = inject(ProjectService);
|
||||
private _plainspaceShareService = inject(PlainspaceShareService);
|
||||
private _matDialogRef =
|
||||
inject<MatDialogRef<DialogCreateProjectComponent>>(MatDialogRef);
|
||||
|
||||
|
|
@ -118,6 +123,8 @@ export class DialogCreateProjectComponent implements OnInit, OnDestroy {
|
|||
const projectDataToSave: Project | Partial<Project> = {
|
||||
...this.projectData,
|
||||
};
|
||||
// Never persist the transient share flag (not part of the Project model).
|
||||
delete (projectDataToSave as Partial<CreateProjectFormModel>).isShareOnPlainspace;
|
||||
if (this._isSaveTmpProject) {
|
||||
saveToSessionStorage(SS.PROJECT_TMP, projectDataToSave);
|
||||
}
|
||||
|
|
@ -145,12 +152,24 @@ export class DialogCreateProjectComponent implements OnInit, OnDestroy {
|
|||
const projectDataToSave: Project | Partial<Project> = {
|
||||
...this.projectData,
|
||||
};
|
||||
// `isShareOnPlainspace` is a transient form-only flag — read it, then strip
|
||||
// it so it is never persisted onto the Project entity.
|
||||
const isShareOnPlainspace = !!(projectDataToSave as Partial<CreateProjectFormModel>)
|
||||
.isShareOnPlainspace;
|
||||
delete (projectDataToSave as Partial<CreateProjectFormModel>).isShareOnPlainspace;
|
||||
|
||||
let newProjectId: string | undefined;
|
||||
if (projectDataToSave.id) {
|
||||
this._projectService.update(projectDataToSave.id, projectDataToSave);
|
||||
} else {
|
||||
newProjectId = this._projectService.add(projectDataToSave);
|
||||
if (isShareOnPlainspace && newProjectId) {
|
||||
// Fire-and-forget: provision a Plainspace space + bound issue provider.
|
||||
this._plainspaceShareService.shareProjectOnPlainspace(
|
||||
newProjectId,
|
||||
projectDataToSave.title || '',
|
||||
);
|
||||
}
|
||||
}
|
||||
this._isSaveTmpProject = false;
|
||||
sessionStorage.removeItem(SS.PROJECT_TMP);
|
||||
|
|
|
|||
|
|
@ -2,44 +2,65 @@ import { ConfigFormSection } from '../config/global-config.model';
|
|||
import { T } from '../../t.const';
|
||||
import { Project } from './project.model';
|
||||
|
||||
export const CREATE_PROJECT_BASIC_CONFIG_FORM_CONFIG: ConfigFormSection<Project> = {
|
||||
// TODO translate
|
||||
title: 'Project Settings & Theme',
|
||||
key: 'basic',
|
||||
// The create-project form carries one transient field beyond the Project model:
|
||||
// `isShareOnPlainspace` (read on submit, then stripped — never persisted). Typing
|
||||
// the section with it keeps the field key type-safe instead of an `as any` cast.
|
||||
export type CreateProjectFormModel = Project & { isShareOnPlainspace?: boolean };
|
||||
|
||||
help: `Very basic settings for your project.`,
|
||||
export const CREATE_PROJECT_BASIC_CONFIG_FORM_CONFIG: ConfigFormSection<CreateProjectFormModel> =
|
||||
{
|
||||
// TODO translate
|
||||
title: 'Project Settings & Theme',
|
||||
key: 'basic',
|
||||
|
||||
items: [
|
||||
{
|
||||
key: 'title',
|
||||
type: 'input',
|
||||
templateOptions: {
|
||||
required: true,
|
||||
label: T.F.PROJECT.FORM_BASIC.L_TITLE,
|
||||
help: `Very basic settings for your project.`,
|
||||
|
||||
items: [
|
||||
{
|
||||
key: 'title',
|
||||
type: 'input',
|
||||
templateOptions: {
|
||||
required: true,
|
||||
label: T.F.PROJECT.FORM_BASIC.L_TITLE,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'theme.primary' as any,
|
||||
type: 'color',
|
||||
templateOptions: {
|
||||
label: T.F.PROJECT.FORM_THEME.L_THEME_COLOR,
|
||||
{
|
||||
key: 'theme.primary' as any,
|
||||
type: 'color',
|
||||
templateOptions: {
|
||||
label: T.F.PROJECT.FORM_THEME.L_THEME_COLOR,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'icon',
|
||||
type: 'icon',
|
||||
templateOptions: {
|
||||
label: T.F.TAG.FORM_BASIC.L_ICON,
|
||||
description: T.G.ICON_INP_DESCRIPTION,
|
||||
{
|
||||
key: 'icon',
|
||||
type: 'icon',
|
||||
templateOptions: {
|
||||
label: T.F.TAG.FORM_BASIC.L_ICON,
|
||||
description: T.G.ICON_INP_DESCRIPTION,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'isEnableBacklog',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
templateOptions: {
|
||||
label: T.F.PROJECT.FORM_BASIC.L_ENABLE_BACKLOG,
|
||||
{
|
||||
key: 'isEnableBacklog',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
templateOptions: {
|
||||
label: T.F.PROJECT.FORM_BASIC.L_ENABLE_BACKLOG,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
{
|
||||
// Transient form-only field (not persisted on the Project). When checked
|
||||
// on create, the dialog provisions a Plainspace space + bound issue
|
||||
// provider. See docs/plainspace-integration-plan.md §6.
|
||||
// Create-only: editing an existing project (model has an id) can't
|
||||
// provision sharing here, so hide it rather than show a dead control.
|
||||
key: 'isShareOnPlainspace',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
hideExpression: '!!model.id',
|
||||
templateOptions: {
|
||||
label: T.PLAINSPACE.SHARE_LABEL,
|
||||
description: T.PLAINSPACE.SHARE_DESCRIPTION,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -50,7 +50,12 @@
|
|||
</button>
|
||||
}
|
||||
@if (
|
||||
!task().notes && (!task().issueId || task().issueType === ICAL_TYPE) && !isSelected()
|
||||
!task().notes &&
|
||||
(!task().issueId ||
|
||||
task().issueType === ICAL_TYPE ||
|
||||
task().issueType === PLAINSPACE_TYPE) &&
|
||||
!task().issueWasUpdated &&
|
||||
!isSelected()
|
||||
) {
|
||||
<button
|
||||
(click)="parent.toggleShowDetailPanel()"
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { T } from 'src/app/t.const';
|
|||
import { TaskComponent } from '../task.component';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { KeyboardConfig } from '@sp/keyboard-config';
|
||||
import { ICAL_TYPE } from '../../../issue/issue.const';
|
||||
import { ICAL_TYPE, PLAINSPACE_TYPE } from '../../../issue/issue.const';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { GlobalConfigService } from '../../../config/global-config.service';
|
||||
|
||||
|
|
@ -44,4 +44,5 @@ export class TaskHoverControlsComponent {
|
|||
}
|
||||
|
||||
protected readonly ICAL_TYPE = ICAL_TYPE;
|
||||
protected readonly PLAINSPACE_TYPE = PLAINSPACE_TYPE;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ import { isDeadlineOverdue as isDeadlineOverdueFn } from '../util/is-deadline-ov
|
|||
import { isDeadlineApproaching as isDeadlineApproachingFn } from '../util/is-deadline-approaching';
|
||||
import { TaskContextMenuComponent } from '../task-context-menu/task-context-menu.component';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ICAL_TYPE } from '../../issue/issue.const';
|
||||
import { ICAL_TYPE, PLAINSPACE_TYPE } from '../../issue/issue.const';
|
||||
import { TaskTitleComponent } from '../../../ui/task-title/task-title.component';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatIconButton, MatMiniFabButton } from '@angular/material/button';
|
||||
|
|
@ -197,8 +197,17 @@ export class TaskComponent implements OnDestroy, AfterViewInit {
|
|||
if (this.checklistProgress() && this.toggleButtonIcon() === 'chat') {
|
||||
return false;
|
||||
}
|
||||
// iCal and Plainspace mirror all their issue data into native task fields, so
|
||||
// their detail panel only repeats what's already on the task. Don't surface the
|
||||
// button just because they carry an issueId — only for real notes, a remote
|
||||
// update (the 'update' icon), or when the panel is open.
|
||||
const isMirroredIssueType =
|
||||
t.issueType === ICAL_TYPE || t.issueType === PLAINSPACE_TYPE;
|
||||
return (
|
||||
t.notes || (t.issueId && t.issueType !== ICAL_TYPE) || this.isShowCloseButton()
|
||||
!!t.notes ||
|
||||
(!!t.issueId && !isMirroredIssueType) ||
|
||||
!!t.issueWasUpdated ||
|
||||
this.isShowCloseButton()
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -380,6 +380,31 @@
|
|||
</div>
|
||||
}
|
||||
|
||||
@if (isShowClaimPool()) {
|
||||
<div
|
||||
@expand
|
||||
class="collapsible-section"
|
||||
>
|
||||
<collapsible
|
||||
[title]="
|
||||
(T.PLAINSPACE.CLAIM_POOL_TITLE | translate) +
|
||||
' (' +
|
||||
unclaimedTasks().length +
|
||||
')'
|
||||
"
|
||||
[isExpanded]="!isClaimPoolHidden()"
|
||||
(isExpandedChange)="isClaimPoolHidden.set(!$event)"
|
||||
[isIconBefore]="true"
|
||||
[isGroup]="true"
|
||||
>
|
||||
<plainspace-claim-pool
|
||||
[tasks]="unclaimedTasks()"
|
||||
[projectId]="workContextService.activeWorkContextId || ''"
|
||||
></plainspace-claim-pool>
|
||||
</collapsible>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isOnTodayList() && isFinishDayEnabled()) {
|
||||
<finish-day-btn [hasDoneTasks]="hasDoneTasks()"></finish-day-btn>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,6 +90,8 @@ import { dragDelayForTouch } from '../../util/input-intent';
|
|||
import { DateService } from '../../core/date/date.service';
|
||||
import { PluginIndexComponent } from '../../plugins/ui/plugin-index/plugin-index.component';
|
||||
import { PluginBridgeService } from '../../plugins/plugin-bridge.service';
|
||||
import { PlainspaceClaimPoolComponent } from '../plainspace/claim-pool/claim-pool.component';
|
||||
import { PlainspaceSharedTask } from '../plainspace/plainspace-shared-task.model';
|
||||
|
||||
// Stable reference used as the toSignal initial value below so the deselect
|
||||
// effect can tell "the customized list hasn't emitted yet" apart from a
|
||||
|
|
@ -129,6 +131,7 @@ const INITIAL_CUSTOMIZED_UNDONE_TASKS: CustomizedUndoneTasks = { list: [] };
|
|||
ScheduledDateGroupPipe,
|
||||
RepeatCfgPreviewComponent,
|
||||
PluginIndexComponent,
|
||||
PlainspaceClaimPoolComponent,
|
||||
],
|
||||
})
|
||||
export class WorkViewComponent implements OnInit, OnDestroy {
|
||||
|
|
@ -220,6 +223,18 @@ export class WorkViewComponent implements OnInit, OnDestroy {
|
|||
isLaterTodayHidden = signal(!!localStorage.getItem(LS.LATER_TODAY_TASKS_HIDDEN));
|
||||
isOverdueHidden = signal(!!localStorage.getItem(LS.OVERDUE_TASKS_HIDDEN));
|
||||
isRepeatCfgsHidden = signal(!!localStorage.getItem(LS.REPEAT_CFGS_HIDDEN));
|
||||
// Claim pool is tucked away (collapsed) by default.
|
||||
isClaimPoolHidden = signal(
|
||||
localStorage.getItem(LS.PLAINSPACE_CLAIM_POOL_HIDDEN) !== 'false',
|
||||
);
|
||||
|
||||
// Unclaimed Plainspace tasks for a shared project, fed from project-task-page
|
||||
// (PlainspaceClaimPoolService). Read-only until claimed; never enters the SP
|
||||
// task store / op-log sync. Empty for non-shared projects and tags.
|
||||
readonly unclaimedTasks = input<PlainspaceSharedTask[]>([]);
|
||||
isShowClaimPool = computed(
|
||||
() => this.isProjectContext() && this.unclaimedTasks().length > 0,
|
||||
);
|
||||
|
||||
repeatCfgsForContext = toSignal(
|
||||
this.workContextService.activeWorkContextTypeAndId$.pipe(
|
||||
|
|
@ -397,6 +412,15 @@ export class WorkViewComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
// Persist 'false' when expanded; default (absent) means collapsed.
|
||||
if (this.isClaimPoolHidden()) {
|
||||
localStorage.removeItem(LS.PLAINSPACE_CLAIM_POOL_HIDDEN);
|
||||
} else {
|
||||
localStorage.setItem(LS.PLAINSPACE_CLAIM_POOL_HIDDEN, 'false');
|
||||
}
|
||||
});
|
||||
|
||||
afterNextRender(() => this._initScrollTracking());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
</div>
|
||||
}
|
||||
<work-view
|
||||
[unclaimedTasks]="unclaimedTasks()"
|
||||
[backlogTasks]="backlogTasks()"
|
||||
[doneTasks]="doneTasks()"
|
||||
[isShowBacklog]="isShowBacklog()"
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@ import { MatButton } from '@angular/material/button';
|
|||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { WorkContextService } from '../../features/work-context/work-context.service';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { distinctUntilChanged, map, switchMap } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
import { WorkViewComponent } from '../../features/work-view/work-view.component';
|
||||
import { ProjectService } from '../../features/project/project.service';
|
||||
import { PlainspaceClaimPoolService } from '../../features/plainspace/plainspace-claim-pool.service';
|
||||
import { PlainspaceSharedTask } from '../../features/plainspace/plainspace-shared-task.model';
|
||||
import { T } from '../../t.const';
|
||||
|
||||
@Component({
|
||||
|
|
@ -18,9 +21,28 @@ import { T } from '../../t.const';
|
|||
export class ProjectTaskPageComponent {
|
||||
workContextService = inject(WorkContextService);
|
||||
private readonly _projectService = inject(ProjectService);
|
||||
private readonly _plainspaceClaimPoolService = inject(PlainspaceClaimPoolService);
|
||||
|
||||
readonly T = T;
|
||||
|
||||
// Unclaimed Plainspace tasks (only for projects shared on Plainspace); fed
|
||||
// into the work view's read-only "claim pool" panel. `currentProject$` re-emits
|
||||
// on every task add/complete/reorder (the project entity carries taskIds), so
|
||||
// distinct on the id first — otherwise the pool re-fetches `/claimable-tasks`
|
||||
// on every task change.
|
||||
readonly unclaimedTasks = toSignal(
|
||||
this._projectService.currentProject$.pipe(
|
||||
map((project) => project?.id ?? null),
|
||||
distinctUntilChanged(),
|
||||
switchMap((projectId) =>
|
||||
projectId
|
||||
? this._plainspaceClaimPoolService.unclaimedTasksForProject$(projectId)
|
||||
: of([] as PlainspaceSharedTask[]),
|
||||
),
|
||||
),
|
||||
{ initialValue: [] as PlainspaceSharedTask[] },
|
||||
);
|
||||
|
||||
isShowBacklog = toSignal(
|
||||
this.workContextService.activeWorkContext$.pipe(
|
||||
map((workContext) => !!workContext.isEnableBacklog),
|
||||
|
|
|
|||
|
|
@ -2892,6 +2892,56 @@ const T = {
|
|||
TIME_SPENT_TODAY_BY_TAG: 'PDS.TIME_SPENT_TODAY_BY_TAG',
|
||||
WEEK: 'PDS.WEEK',
|
||||
},
|
||||
PLAINSPACE: {
|
||||
CLAIM: 'PLAINSPACE.CLAIM',
|
||||
CLAIM_FAILED: 'PLAINSPACE.CLAIM_FAILED',
|
||||
CLAIM_POOL_TITLE: 'PLAINSPACE.CLAIM_POOL_TITLE',
|
||||
CLAIM_SUCCESS: 'PLAINSPACE.CLAIM_SUCCESS',
|
||||
CONNECT: {
|
||||
CONNECT: 'PLAINSPACE.CONNECT.CONNECT',
|
||||
CONNECTING: 'PLAINSPACE.CONNECT.CONNECTING',
|
||||
EMAIL_HINT: 'PLAINSPACE.CONNECT.EMAIL_HINT',
|
||||
INTRO: 'PLAINSPACE.CONNECT.INTRO',
|
||||
INVALID: 'PLAINSPACE.CONNECT.INVALID',
|
||||
OPEN_LINK: 'PLAINSPACE.CONNECT.OPEN_LINK',
|
||||
STEP_1: 'PLAINSPACE.CONNECT.STEP_1',
|
||||
STEP_2: 'PLAINSPACE.CONNECT.STEP_2',
|
||||
STEP_3: 'PLAINSPACE.CONNECT.STEP_3',
|
||||
STEP_4: 'PLAINSPACE.CONNECT.STEP_4',
|
||||
TITLE: 'PLAINSPACE.CONNECT.TITLE',
|
||||
TOKEN_LABEL: 'PLAINSPACE.CONNECT.TOKEN_LABEL',
|
||||
TOKEN_PLACEHOLDER: 'PLAINSPACE.CONNECT.TOKEN_PLACEHOLDER',
|
||||
},
|
||||
FORM: {
|
||||
HOST: 'PLAINSPACE.FORM.HOST',
|
||||
SPACE_ID: 'PLAINSPACE.FORM.SPACE_ID',
|
||||
SPACE_ID_DESCRIPTION: 'PLAINSPACE.FORM.SPACE_ID_DESCRIPTION',
|
||||
TOKEN: 'PLAINSPACE.FORM.TOKEN',
|
||||
TOKEN_DESCRIPTION: 'PLAINSPACE.FORM.TOKEN_DESCRIPTION',
|
||||
},
|
||||
FORM_SECTION: {
|
||||
HELP: 'PLAINSPACE.FORM_SECTION.HELP',
|
||||
TITLE: 'PLAINSPACE.FORM_SECTION.TITLE',
|
||||
},
|
||||
LOGIN_REQUIRED: 'PLAINSPACE.LOGIN_REQUIRED',
|
||||
NO_UNCLAIMED: 'PLAINSPACE.NO_UNCLAIMED',
|
||||
OPEN_IN_PLAINSPACE: 'PLAINSPACE.OPEN_IN_PLAINSPACE',
|
||||
RECURRING: 'PLAINSPACE.RECURRING',
|
||||
SHARE_DESCRIPTION: 'PLAINSPACE.SHARE_DESCRIPTION',
|
||||
SHARE_FAILED: 'PLAINSPACE.SHARE_FAILED',
|
||||
SHARE_LABEL: 'PLAINSPACE.SHARE_LABEL',
|
||||
SHARE_SUCCESS: 'PLAINSPACE.SHARE_SUCCESS',
|
||||
SPACE_PICKER: {
|
||||
CREATE_NEW: 'PLAINSPACE.SPACE_PICKER.CREATE_NEW',
|
||||
ERROR: 'PLAINSPACE.SPACE_PICKER.ERROR',
|
||||
INTRO: 'PLAINSPACE.SPACE_PICKER.INTRO',
|
||||
LINK: 'PLAINSPACE.SPACE_PICKER.LINK',
|
||||
LOADING: 'PLAINSPACE.SPACE_PICKER.LOADING',
|
||||
NONE: 'PLAINSPACE.SPACE_PICKER.NONE',
|
||||
SELECT_LABEL: 'PLAINSPACE.SPACE_PICKER.SELECT_LABEL',
|
||||
TITLE: 'PLAINSPACE.SPACE_PICKER.TITLE',
|
||||
},
|
||||
},
|
||||
PLUGINS: {
|
||||
ACTION_TYPE_NOT_ALLOWED: 'PLUGINS.ACTION_TYPE_NOT_ALLOWED',
|
||||
ALREADY_INITIALIZED: 'PLUGINS.ALREADY_INITIALIZED',
|
||||
|
|
|
|||
|
|
@ -2827,6 +2827,56 @@
|
|||
"TIME_SPENT_TODAY_BY_TAG": "Time Spent today by Tag",
|
||||
"WEEK": "Week"
|
||||
},
|
||||
"PLAINSPACE": {
|
||||
"CLAIM": "Claim",
|
||||
"CLAIM_FAILED": "Couldn't claim this task — it may have just been claimed by someone else, or you're offline.",
|
||||
"CLAIM_POOL_TITLE": "Available to claim",
|
||||
"CLAIM_SUCCESS": "Task claimed and added to your list.",
|
||||
"CONNECT": {
|
||||
"CONNECT": "Connect",
|
||||
"CONNECTING": "Connecting…",
|
||||
"EMAIL_HINT": "A Space needs a verified email before it can create tokens.",
|
||||
"INTRO": "Paste a Plainspace API token to import the tasks assigned to you and to share projects. Creating one in Plainspace only takes a moment:",
|
||||
"INVALID": "That token didn't work. Make sure you copied the whole token (it starts with \"pat_\") and try again.",
|
||||
"OPEN_LINK": "Open Plainspace",
|
||||
"STEP_1": "Open Plainspace and pick one of your Spaces (or create one).",
|
||||
"STEP_2": "Open the People panel, then expand Advanced → API tokens.",
|
||||
"STEP_3": "Name a token (e.g. \"Super Productivity\") and create it.",
|
||||
"STEP_4": "Copy the token and paste it below.",
|
||||
"TITLE": "Connect to Plainspace",
|
||||
"TOKEN_LABEL": "API token",
|
||||
"TOKEN_PLACEHOLDER": "pat_…"
|
||||
},
|
||||
"FORM": {
|
||||
"HOST": "Plainspace host",
|
||||
"SPACE_ID": "Space ID",
|
||||
"SPACE_ID_DESCRIPTION": "The space this provider syncs with — paste the id or the slug shown in the space URL (plainspace.org/<slug>/…).",
|
||||
"TOKEN": "API token",
|
||||
"TOKEN_DESCRIPTION": "Personal API token (starts with \"pat_\"). In Plainspace, open a Space → People → Advanced → API tokens to create one."
|
||||
},
|
||||
"FORM_SECTION": {
|
||||
"HELP": "Connect a Plainspace space with a personal API token to import tasks assigned to you and claim unclaimed tasks.",
|
||||
"TITLE": "Plainspace"
|
||||
},
|
||||
"LOGIN_REQUIRED": "A Plainspace API token is required to share a project.",
|
||||
"NO_UNCLAIMED": "No unclaimed tasks right now.",
|
||||
"OPEN_IN_PLAINSPACE": "Open in Plainspace",
|
||||
"RECURRING": "Recurring task",
|
||||
"SHARE_DESCRIPTION": "Creates a shared Plainspace space and links it to this project so you can collaborate with others.",
|
||||
"SHARE_FAILED": "Could not share this project on Plainspace.",
|
||||
"SHARE_LABEL": "Collaborate on this project on Plainspace (Beta)",
|
||||
"SHARE_SUCCESS": "Project shared on Plainspace. Tasks assigned to you will import automatically.",
|
||||
"SPACE_PICKER": {
|
||||
"CREATE_NEW": "Create new space",
|
||||
"ERROR": "Couldn't reach Plainspace. Check your connection and that your token is still valid, then try again.",
|
||||
"INTRO": "Link an existing space to import the tasks assigned to you, or create a new one.",
|
||||
"LINK": "Link space",
|
||||
"LOADING": "Loading your spaces…",
|
||||
"NONE": "You don't have any spaces yet — create one to get started.",
|
||||
"SELECT_LABEL": "Existing space",
|
||||
"TITLE": "Choose a Plainspace space"
|
||||
}
|
||||
},
|
||||
"PLUGINS": {
|
||||
"ACTION_TYPE_NOT_ALLOWED": "Action type \"{{type}}\" is not allowed",
|
||||
"ALREADY_INITIALIZED": "Plugin is already initialized",
|
||||
|
|
|
|||
4
src/assets/icons/plainspace.svg
Normal file
4
src/assets/icons/plainspace.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="29 29 70 70" fill="currentColor">
|
||||
<circle cx="56" cy="64" r="27" opacity="0.85" />
|
||||
<circle cx="72" cy="64" r="27" opacity="0.55" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 192 B |
Loading…
Add table
Add a link
Reference in a new issue