@softr/sdk 0.0.74 → 0.0.75-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/action.d.ts +31 -1
- package/dist/action.js +26 -1
- package/dist/client.d.ts +2 -1
- package/dist/client.js +2 -1
- package/dist/codegen/datasource-types.js +1 -14
- package/dist/comments/comments.d.ts +168 -0
- package/dist/comments/comments.js +413 -0
- package/dist/comments/use-comment-composer.d.ts +46 -0
- package/dist/comments/use-comment-composer.js +134 -0
- package/dist/core/file-upload.d.ts +7 -2
- package/dist/core/file-upload.js +6 -1
- package/dist/core/options.d.ts +17 -2
- package/dist/core/options.js +30 -2
- package/dist/core/use-file-upload.d.ts +2 -2
- package/dist/datasource/datasource.d.ts +2 -1
- package/dist/datasource/datasource.js +3 -2
- package/dist/datasource/fields.d.ts +16 -24
- package/dist/default-error-overlay.d.ts +2 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -2
- package/dist/package.js +1 -1
- package/dist/pwa.d.ts +2 -2
- package/dist/server.d.ts +3 -2
- package/dist/server.js +2 -1
- package/package.json +3 -1
- package/skills/softr-sdk/SKILL.md +8 -4
- package/skills/softr-sdk/auth/SKILL.md +1 -1
- package/skills/softr-sdk/charts-and-metrics/SKILL.md +1 -1
- package/skills/softr-sdk/comments/SKILL.md +181 -0
- package/skills/softr-sdk/current-user/SKILL.md +1 -1
- package/skills/softr-sdk/file-uploads/SKILL.md +5 -4
- package/skills/softr-sdk/formatting/SKILL.md +1 -1
- package/skills/softr-sdk/integrations/SKILL.md +1 -1
- package/skills/softr-sdk/mcp/SKILL.md +1 -1
- package/skills/softr-sdk/pwa/SKILL.md +1 -1
- package/skills/softr-sdk/record-mutations/SKILL.md +2 -2
- package/skills/softr-sdk/record-queries/SKILL.md +7 -1
- package/skills/softr-sdk/record-queries/references/filters-and-sorting.md +1 -1
- package/skills/softr-sdk/region-visibility/SKILL.md +1 -1
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +1 -1
- package/skills/softr-sdk/workflows/SKILL.md +1 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: comments
|
|
3
|
+
description: >-
|
|
4
|
+
Comment threads on records: defineComments from @softr/sdk declares which
|
|
5
|
+
datasource has comments; comments.list, create, update, delete, react,
|
|
6
|
+
subscription, setSubscription and searchMentions from @softr/sdk/server read
|
|
7
|
+
and write one record's thread inside a server function; useCommentComposer
|
|
8
|
+
and useFileUpload from @softr/sdk/client keep a textarea's text, its
|
|
9
|
+
@mentions and its attachments in step. Bodies come back as sanitised
|
|
10
|
+
bodyHtml. A null thread means render nothing.
|
|
11
|
+
metadata:
|
|
12
|
+
type: sub-skill
|
|
13
|
+
library: softr-sdk
|
|
14
|
+
library_version: 0.0.75
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
**Boundary.** `comments` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler. Importing it into a component fails the build. `useCommentComposer` lives on `@softr/sdk/client` and runs in components. `defineComments` comes from `@softr/sdk` and can be imported anywhere.
|
|
18
|
+
|
|
19
|
+
# Comments on records
|
|
20
|
+
|
|
21
|
+
Each record of a commented datasource has one thread. The SDK reads and writes it. The app owns what you see: layout, copy, order, dates.
|
|
22
|
+
|
|
23
|
+
## Setup
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
27
|
+
import { defineComments, type CommentMention, type CommentAttachmentInput } from '@softr/sdk'
|
|
28
|
+
import { comments } from '@softr/sdk/server'
|
|
29
|
+
import { useCommentComposer, useFileUpload } from '@softr/sdk/client'
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Declare the comments action
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
export const orderComments = defineComments({
|
|
36
|
+
id: 'orderComments',
|
|
37
|
+
datasourceId: '8f14e45f-ceea-467a-9d0b-2c1a0e3b5d77',
|
|
38
|
+
})
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- `id` is your name for it, unique in the app. `datasourceId` is the table that gets threads.
|
|
42
|
+
- No `permissions`. Who can read, post and delete is set in Settings → Comments, shared with native blocks.
|
|
43
|
+
- A viewer sees a thread only if a `read` action (`defineQuery`) on the same table lets them read it. Without one, `list` returns `null` for them.
|
|
44
|
+
- One declaration per datasource. Two on the same table are the **same** threads, so "internal notes" plus "customer comments" on one table isn't possible.
|
|
45
|
+
- Write the object inline. A variable hides it from the build.
|
|
46
|
+
|
|
47
|
+
## Read a thread
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
const getThread = createServerFn({ method: 'GET' })
|
|
51
|
+
.validator((data: { recordId: string; offset?: number | null }) => data)
|
|
52
|
+
.handler(({ data }) => comments.list(orderComments, { recordId: data.recordId, offset: data.offset }))
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`comments.list` returns `null` when this visitor can't see comments on the record (comments are off, the table isn't set up, or their group can't read). Render nothing for `null`, not an error.
|
|
56
|
+
|
|
57
|
+
Otherwise it returns:
|
|
58
|
+
|
|
59
|
+
| Field | What it is |
|
|
60
|
+
| --- | --- |
|
|
61
|
+
| `items` | This page's comments, newest first. Each is a `CommentView` |
|
|
62
|
+
| `canPost` | Whether the visitor can post. Hide the composer when false |
|
|
63
|
+
| `allowMentions` | Whether `@` opens a mention menu. Don't call `searchMentions` when false |
|
|
64
|
+
| `allowAttachments` | Whether the composer can attach files |
|
|
65
|
+
| `notificationsEnabled` | Whether to show the notifications switch at all |
|
|
66
|
+
| `quickReactions` | The five `{ emoji, label }` reactions. There are no others |
|
|
67
|
+
| `total`, `nextOffset` | Paging. Pass `nextOffset` back as `offset` to get the next, older page. `null` on the last page |
|
|
68
|
+
|
|
69
|
+
A `CommentView` is `{ id, bodyHtml, attachments, author: { name, avatar, isDeactivated }, createdAt, canDelete, canEdit, edited, source, reactions }`.
|
|
70
|
+
|
|
71
|
+
- `createdAt` is ISO 8601. Show it with `<FormattedDate value={comment.createdAt} time />`, or `useFormatting` for a title; see the `formatting` guidance.
|
|
72
|
+
- `attachments` are `{ url, filename, type }`. `type` is `null` when unknown.
|
|
73
|
+
- `edited` is true once the comment changed after posting.
|
|
74
|
+
- `source` is `{ text, mentions }` for the edit box, or `null` when `canEdit` is false.
|
|
75
|
+
- Each reaction is `{ emoji, label, count, isSelected }`.
|
|
76
|
+
|
|
77
|
+
`bodyHtml` is already sanitised. Render it with `dangerouslySetInnerHTML` and nothing else:
|
|
78
|
+
|
|
79
|
+
```tsx
|
|
80
|
+
<div className="comment-body" dangerouslySetInnerHTML={{ __html: comment.bodyHtml }} />
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
It holds `p`, `br`, `strong`, `em`, `u`, `s`, `del`, `a`, `ul`, `ol`, `li`, `code`, `pre`, `blockquote`, `h1`–`h3` and mentions (`span[data-type="mention"]`). Links open in a new tab. Style these tags under the body's class: the reset strips bullets, link colour and spacing.
|
|
84
|
+
|
|
85
|
+
## Post a comment
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
const postComment = createServerFn({ method: 'POST' })
|
|
89
|
+
.validator(
|
|
90
|
+
(data: {
|
|
91
|
+
recordId: string
|
|
92
|
+
body: string
|
|
93
|
+
mentions: Array<CommentMention>
|
|
94
|
+
attachments?: Array<CommentAttachmentInput>
|
|
95
|
+
}) => data,
|
|
96
|
+
)
|
|
97
|
+
.handler(({ data }) => comments.create(orderComments, data))
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Pass the composer's `text` and `mentions` as they are. Mentioned people get an email. A mention whose `@name` was edited posts as plain text.
|
|
101
|
+
|
|
102
|
+
`attachments` are the finished uploads: `useFileUpload(orderComments).files.filter((f) => f.status === 'done')`. `useFileUpload` takes the comments declaration like a mutation. Send files only when `allowAttachments` is true. A comment needs text or at least one file.
|
|
103
|
+
|
|
104
|
+
## The composer
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
const composer = useCommentComposer()
|
|
108
|
+
const { files, isUploading, upload, remove } = useFileUpload(orderComments)
|
|
109
|
+
const done = files.filter((f) => f.status === 'done')
|
|
110
|
+
|
|
111
|
+
<textarea {...composer.textareaProps} placeholder="Write a comment" />
|
|
112
|
+
{/* While composer.query is not null, list findPeople's answer for it. Picking a person calls composer.pick(person). */}
|
|
113
|
+
<button
|
|
114
|
+
disabled={(composer.text.trim() === '' && done.length === 0) || isUploading}
|
|
115
|
+
onClick={async () => {
|
|
116
|
+
await postComment({ data: { recordId, body: composer.text, mentions: composer.mentions, attachments: done } })
|
|
117
|
+
composer.reset()
|
|
118
|
+
done.forEach((f) => remove(f.id))
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
Post
|
|
122
|
+
</button>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
- Spread `textareaProps` onto the `<textarea>` and don't override its handlers.
|
|
126
|
+
- `query` is the text after `@` before the caret, or `null`. It's `""` right after `@` is typed.
|
|
127
|
+
- `pick({ name, email })` writes `@name`, records the mention and moves the caret after it.
|
|
128
|
+
- `reset()` empties the box. Call it only after the post succeeds.
|
|
129
|
+
|
|
130
|
+
## Editing a comment
|
|
131
|
+
|
|
132
|
+
Offer Edit only where `canEdit` is true. Mount the edit box only while editing.
|
|
133
|
+
|
|
134
|
+
- Text: `useCommentComposer({ initial: comment.source })`.
|
|
135
|
+
- Files: `useFileUpload(orderComments, { initialAttachments: comment.attachments.map((a) => ({ ...a, id: a.url })) })`. Attachments have no `id`, so the url stands in.
|
|
136
|
+
- Save with `comments.update`, passing the full file list: the kept files plus new uploads. With `allowAttachments` false the server keeps the old files whatever you send.
|
|
137
|
+
|
|
138
|
+
## Mentions
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
const findPeople = createServerFn({ method: 'GET' })
|
|
142
|
+
.validator((data: { query: string }) => data)
|
|
143
|
+
.handler(({ data }) => comments.searchMentions(orderComments, data))
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
It returns `[{ name, email, avatar }]`: people the visitor can mention whose name or email matches. Who's mentionable is set per user group in Settings → Comments.
|
|
147
|
+
|
|
148
|
+
## Delete, update, react, subscribe
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
await comments.delete(orderComments, { recordId, commentId })
|
|
152
|
+
await comments.update(orderComments, { recordId, commentId, body, mentions, attachments })
|
|
153
|
+
await comments.react(orderComments, { recordId, commentId, emoji: '👍', selected: !reaction.isSelected })
|
|
154
|
+
const subscribed = await comments.subscription(orderComments, { recordId })
|
|
155
|
+
await comments.setSubscription(orderComments, { recordId, subscribed: !subscribed })
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Each runs in its own server function, like `list`. Writes return nothing, so re-read the thread after each one.
|
|
159
|
+
|
|
160
|
+
- Offer delete only where `canDelete` is true.
|
|
161
|
+
- `react` adds the reaction when `selected` is true and removes it when false. `emoji` must be one of `quickReactions`.
|
|
162
|
+
- A subscription means the visitor gets an email about new comments on the record. It needs a signed-in visitor. Posting subscribes the author; a mention subscribes the person mentioned.
|
|
163
|
+
- Mails go out a few minutes after a comment, never to its author, and only while notifications are on in Settings → Comments.
|
|
164
|
+
|
|
165
|
+
## Failure modes
|
|
166
|
+
|
|
167
|
+
**Keying the thread on the URL.** Pass the fetched `record.id`, never a route or search param. A param can be a slug or stale, and the thread shows another record's comments.
|
|
168
|
+
|
|
169
|
+
**Treating `null` as an error.** Render nothing. An error box shows every such visitor a broken page.
|
|
170
|
+
|
|
171
|
+
**Rendering a body as text, or sanitising it again.** Use `bodyHtml` with `dangerouslySetInnerHTML`. As text it shows raw tags; a second sanitiser strips the mentions.
|
|
172
|
+
|
|
173
|
+
**Inventing reactions.** Only `quickReactions` can be stored. `react` throws for anything else.
|
|
174
|
+
|
|
175
|
+
**Sending only new files on edit.** `update` replaces the whole list, so kept files you leave out get deleted.
|
|
176
|
+
|
|
177
|
+
**Calling `comments.*` from a component.** It's server only. Wrap each call in a `createServerFn`.
|
|
178
|
+
|
|
179
|
+
## References
|
|
180
|
+
|
|
181
|
+
- [record-queries/SKILL.md](../record-queries/SKILL.md): fetching the record whose `id` keys the thread
|
|
@@ -10,7 +10,7 @@ description: >-
|
|
|
10
10
|
metadata:
|
|
11
11
|
type: sub-skill
|
|
12
12
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.
|
|
13
|
+
library_version: 0.0.75
|
|
14
14
|
---
|
|
15
15
|
|
|
16
16
|
**Boundary.** `upload` lives on `@softr/sdk/server` and runs inside server functions. `useFileUpload` and `FileItem` live on `@softr/sdk` and run in components. `FileAttachment` comes from `@softr/sdk` and can be imported anywhere.
|
|
@@ -75,9 +75,10 @@ function ApplicationForm() {
|
|
|
75
75
|
|
|
76
76
|
```ts
|
|
77
77
|
type FileAttachment = {
|
|
78
|
-
url: string
|
|
79
|
-
filename: string
|
|
80
|
-
id: string
|
|
78
|
+
url: string // clean URL, no internal query params
|
|
79
|
+
filename: string // original filename
|
|
80
|
+
id: string // file id extracted from the upload response
|
|
81
|
+
type?: string | null // content type from the upload response, or null when the platform doesn't know it
|
|
81
82
|
}
|
|
82
83
|
```
|
|
83
84
|
|
|
@@ -10,7 +10,7 @@ description: >-
|
|
|
10
10
|
metadata:
|
|
11
11
|
type: sub-skill
|
|
12
12
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.
|
|
13
|
+
library_version: 0.0.75
|
|
14
14
|
---
|
|
15
15
|
|
|
16
16
|
**Boundary.** `integration` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineIntegration` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.75
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `softrMcpMiddleware` lives on `@softr/sdk` and is registered once, in `src/start.ts`. The actions you hand it are the same `defineQuery` / `defineMutation` values the app's own pages use — declared wherever they already live, not duplicated for MCP.
|
|
@@ -11,7 +11,7 @@ description: >-
|
|
|
11
11
|
metadata:
|
|
12
12
|
type: sub-skill
|
|
13
13
|
library: softr-sdk
|
|
14
|
-
library_version: 0.0.
|
|
14
|
+
library_version: 0.0.75
|
|
15
15
|
---
|
|
16
16
|
|
|
17
17
|
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineMutation` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -155,7 +155,7 @@ A form select for a `LINKED_RECORD` field needs its options from the linked tabl
|
|
|
155
155
|
|
|
156
156
|
Each key of `data` accepts exactly what its field takes (see [record-queries](../record-queries/SKILL.md#typing)):
|
|
157
157
|
|
|
158
|
-
- SELECT and LINKED_RECORD take `{ id }`. Not a bare string, and not the `{ id, label }` a read returns; the label is display data and a write does not accept it.
|
|
158
|
+
- SELECT and LINKED_RECORD take `{ id }`. Not a bare string, and not the `{ id, label }` a read returns; the label is display data and a write does not accept it. For a form select that should follow the field's choices in Studio, read them with `ds.choices` (see [record-queries](../record-queries/SKILL.md)).
|
|
159
159
|
- CHECKBOX takes a boolean. Dates take ISO strings, never `Date` instances.
|
|
160
160
|
- ATTACHMENT takes `{ url }`, optionally with `filename`, `id` and `type`.
|
|
161
161
|
- A multi-entry field takes an array of the value.
|
|
@@ -13,7 +13,7 @@ description: >-
|
|
|
13
13
|
metadata:
|
|
14
14
|
type: sub-skill
|
|
15
15
|
library: softr-sdk
|
|
16
|
-
library_version: 0.0.
|
|
16
|
+
library_version: 0.0.75
|
|
17
17
|
---
|
|
18
18
|
|
|
19
19
|
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. The same holds for any helper that calls `ds`: declare it inside the handler. A function at module level that calls `ds` keeps the server-only import in the browser bundle, and every page stops loading until it moves. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -200,6 +200,12 @@ const loadProductStatuses = createServerFn()
|
|
|
200
200
|
|
|
201
201
|
Skip this request when the options are predefined and fixed — hardcode them instead.
|
|
202
202
|
|
|
203
|
+
A `SELECT` field's options are its choices, and the builder can add and rename them in Studio after the app is built. When a dropdown should follow those changes, read them with `ds.choices`. When the app means a fixed choice, such as a "Done" tab, use its id directly. A mistyped id is not caught at compile time, and a write with an id the field lacks adds it as a new choice. `ds.choices` takes any query or mutation that declares the field and answers `[{ id, label, color }]` as the field holds them now. Each `id` is what a `where` and a write take, for a filter dropdown and a form select alike:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
const loadStatuses = createServerFn().handler(() => ds.choices(listProjects, 'status'))
|
|
207
|
+
```
|
|
208
|
+
|
|
203
209
|
A read the datasource turns down throws rather than coming back empty, and it throws a plain `Error` carrying the status, followed by the reason the datasource gives when it sends one. There is no typed predicate to narrow it with. Handle it where you would handle any other failed fetch: an `errorComponent` on the route, or a check in the handler.
|
|
204
210
|
|
|
205
211
|
## Typing
|
|
@@ -83,7 +83,7 @@ Every value is an ISO date string, either `'2026-01-31'` or a full timestamp. A
|
|
|
83
83
|
| `.isOneOf(values)` / `.isNoneOf(values)` | Any of / none of |
|
|
84
84
|
| `.isEmpty()` / `.isNotEmpty()` | No value / has a value |
|
|
85
85
|
|
|
86
|
-
Values are choice ids, never labels. A
|
|
86
|
+
Values are choice ids, never labels. A SELECT takes any choice id as a string, so a filter dropdown can pass the ids `ds.choices` returns. A label still matches nothing. A LINKED_RECORD compares against the linked record's id.
|
|
87
87
|
|
|
88
88
|
### Multi-value fields
|
|
89
89
|
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.75
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `workflow` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineWorkflow` and every type come from `@softr/sdk` and can be imported anywhere.
|