@stacksjs/defaults 0.75.422 → 0.75.431

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.
Files changed (37) hide show
  1. package/ai/skills/stacks-alias/SKILL.md +8 -0
  2. package/ai/skills/stacks-arrays/SKILL.md +3 -1
  3. package/ai/skills/stacks-build/SKILL.md +3 -0
  4. package/ai/skills/stacks-cache/SKILL.md +13 -9
  5. package/ai/skills/stacks-calendar/SKILL.md +20 -12
  6. package/ai/skills/stacks-chat/SKILL.md +22 -9
  7. package/ai/skills/stacks-database/SKILL.md +10 -0
  8. package/ai/skills/stacks-desktop/SKILL.md +7 -0
  9. package/ai/skills/stacks-events/SKILL.md +6 -2
  10. package/ai/skills/stacks-i18n/SKILL.md +9 -10
  11. package/ai/skills/stacks-native/CATALOG.md +70 -62
  12. package/ai/skills/stacks-notifications/SKILL.md +11 -2
  13. package/ai/skills/stacks-objects/SKILL.md +6 -1
  14. package/ai/skills/stacks-payments/SKILL.md +5 -3
  15. package/ai/skills/stacks-push/SKILL.md +14 -9
  16. package/ai/skills/stacks-queue/SKILL.md +7 -0
  17. package/ai/skills/stacks-slug/SKILL.md +7 -1
  18. package/ai/skills/stacks-strings/SKILL.md +19 -10
  19. package/ai/skills/stacks-testing/SKILL.md +5 -1
  20. package/ai/skills/stacks-utils/SKILL.md +1 -1
  21. package/ai/skills/stacks-validation/SKILL.md +20 -4
  22. package/ai/skills/stacks-whois/SKILL.md +5 -4
  23. package/app/Actions/Dashboard/Content/post-input.ts +1 -1
  24. package/app/Actions/InstallAction.ts +3 -3
  25. package/app/Models/Content/Post.ts +1 -1
  26. package/app/Models/Error.ts +1 -1
  27. package/app/Models/Log.ts +1 -1
  28. package/app/Models/PaymentMethod.ts +3 -3
  29. package/app/Models/PaymentProduct.ts +1 -1
  30. package/app/Models/Subscription.ts +1 -1
  31. package/app/Models/commerce/PrintDevice.ts +1 -1
  32. package/app/Models/commerce/Receipt.ts +1 -1
  33. package/app/Models/realtime/Websocket.ts +2 -2
  34. package/ide/vscode/package.json +1 -1
  35. package/package.json +2 -2
  36. package/resources/views/cms/blocks/form.stx +31 -36
  37. package/routes/forms.ts +14 -5
@@ -8,6 +8,7 @@
8
8
  <div class="mx-auto px-6 py-8 max-w-xl cms-form" data-form-uuid="{{ props.formUuid }}">
9
9
  <script client>
10
10
  import type { PublicFormDefinition, PublicFormField, SubmissionErrors } from '@stacksjs/forms'
11
+ import { visibleFields } from '@stacksjs/forms/conditions'
11
12
  import { moneyInputError, moneyInputStep, parseMoneyInput } from '@stacksjs/commerce/money'
12
13
 
13
14
  // `_form` carries a whole-form failure; the rest are keyed by field name.
@@ -15,7 +16,7 @@
15
16
 
16
17
  const host = useRef<HTMLElement>('host')
17
18
  const definition = state<PublicFormDefinition | null>(null)
18
- const values = reactive<Record<string, string | number | boolean>>({})
19
+ const values = state<Record<string, string | number | boolean>>({})
19
20
  const errors = state<FormErrors>({})
20
21
  const sending = state(false)
21
22
  const done = state('')
@@ -27,7 +28,7 @@
27
28
  // rather than only the storage path the server answered with.
28
29
  const attached = reactive<Record<string, string>>({})
29
30
 
30
- effect(() => {
31
+ onMount(() => {
31
32
  if (!host() || definition())
32
33
  return
33
34
  const uuid = host()?.dataset.formUuid
@@ -39,25 +40,19 @@
39
40
  .catch(() => {})
40
41
  })
41
42
 
43
+ function setFieldValue(name: string, value: string | number | boolean): void {
44
+ values.set({ ...values(), [name]: value })
45
+ }
46
+
47
+ function clearFieldValue(name: string): void {
48
+ const next = { ...values() }
49
+ delete next[name]
50
+ values.set(next)
51
+ }
52
+
42
53
  function fieldVisible(field: PublicFormField): boolean {
43
- const conditions = field.conditions
44
- if (!conditions || !conditions.rules || conditions.rules.length === 0)
45
- return true
46
- const results = conditions.rules.map((rule) => {
47
- const actual = values[rule.field]
48
- switch (rule.op) {
49
- case 'eq': return String(actual ?? '') === String(rule.value ?? '')
50
- case 'neq': return String(actual ?? '') !== String(rule.value ?? '')
51
- case 'contains': return String(actual ?? '').includes(String(rule.value ?? ''))
52
- case 'gt': return Number(actual) > Number(rule.value)
53
- case 'lt': return Number(actual) < Number(rule.value)
54
- case 'empty': return actual === undefined || actual === null || String(actual) === ''
55
- case 'not_empty': return actual !== undefined && actual !== null && String(actual) !== ''
56
- default: return false
57
- }
58
- })
59
- const matched = conditions.logic === 'all' ? results.every(Boolean) : results.some(Boolean)
60
- return conditions.action === 'show' ? matched : !matched
54
+ try { return visibleFields(definition()?.fields ?? [], values()).includes(field) }
55
+ catch { return false }
61
56
  }
62
57
 
63
58
  /**
@@ -97,13 +92,13 @@
97
92
  // Clear the input as well as the value: leaving a rejected file
98
93
  // showing next to its own error message reads as though it was taken.
99
94
  input.value = ''
100
- delete values[field.name]
95
+ clearFieldValue(field.name)
101
96
  delete attached[field.name]
102
97
  errors.set({ ...errors(), [field.name]: (failure as { data?: { message?: string } }).data?.message ?? 'That file could not be uploaded.' })
103
98
  return
104
99
  }
105
100
 
106
- values[field.name] = body.path ?? ''
101
+ setFieldValue(field.name, body.path ?? '')
107
102
  attached[field.name] = file.name
108
103
  }
109
104
  catch {
@@ -128,7 +123,7 @@
128
123
  if (field.type === 'currency')
129
124
  setAmount(field, raw)
130
125
  else
131
- values[field.name] = raw
126
+ setFieldValue(field.name, raw)
132
127
  }
133
128
 
134
129
  /**
@@ -149,16 +144,16 @@
149
144
  const currency = formCurrency()
150
145
  const error = moneyInputError(raw, currency, { required: false })
151
146
  if (error) {
152
- values[field.name] = raw
147
+ setFieldValue(field.name, raw)
153
148
  errors.set({ ...errors(), [field.name]: error })
154
149
  return
155
150
  }
156
151
 
157
152
  const minor = parseMoneyInput(raw, currency, { required: false })
158
153
  if (minor === null)
159
- delete values[field.name]
154
+ clearFieldValue(field.name)
160
155
  else
161
- values[field.name] = minor
156
+ setFieldValue(field.name, minor)
162
157
  if (errors()[field.name])
163
158
  errors.set({ ...errors(), [field.name]: '' })
164
159
  }
@@ -199,7 +194,7 @@
199
194
  // 403 "CSRF token mismatch" on every submission.
200
195
  let body: { message?: string, redirect?: string }
201
196
  try {
202
- body = await useMutation(`/api/forms/${uuid}/submissions`).mutate({ ...values, _renderedMs: Date.now() - openedAt })
197
+ body = await useMutation(`/api/forms/${uuid}/submissions`).mutate({ ...values(), _renderedMs: Date.now() - openedAt })
203
198
  }
204
199
  catch (failure) {
205
200
  const { status, data } = failure as { status?: number, data?: { errors?: FormErrors, message?: string } }
@@ -232,41 +227,41 @@
232
227
 
233
228
  <div class="grid gap-5 grid-cols-1 sm:grid-cols-2 mt-6">
234
229
  <template :for="field in definition()?.fields ?? []">
235
- <div :if="fieldVisible(field)" class="{{ field.width === 'half' ? '' : 'sm:col-span-2' }}">
230
+ <div :if="fieldVisible(field)" :class="field.width === 'half' ? '' : 'sm:col-span-2'">
236
231
  <h3 :if="field.type === 'section_break'" class="mt-2 pt-4 font-medium text-lg border-gray-200 border-t">{{ field.label }}</h3>
237
232
  <template :else>
238
233
  <label class="block font-medium text-[14px] text-gray-800">{{ field.label }}<span :if="field.required" aria-hidden="true" class="text-red-500"> *</span></label>
239
- <textarea :if="field.type === 'textarea'" rows="4" :required="field.required" placeholder="{{ field.options.placeholder ?? '' }}" @input="values[field.name] = $event.target.value" class="mt-1.5 px-3.5 py-2.5 w-full text-[15px] border border-gray-300 rounded-lg"></textarea>
240
- <select :else-if="field.type === 'select'" :required="field.required" @change="values[field.name] = $event.target.value" class="mt-1.5 px-3.5 py-2.5 w-full text-[15px] bg-white border border-gray-300 rounded-lg">
234
+ <textarea :if="field.type === 'textarea'" rows="4" :required="field.required" :placeholder="field.options.placeholder ?? ''" @input="setFieldValue(field.name, $event.target.value)" class="mt-1.5 px-3.5 py-2.5 w-full text-[15px] border border-gray-300 rounded-lg"></textarea>
235
+ <select :else-if="field.type === 'select'" :required="field.required" @change="setFieldValue(field.name, $event.target.value)" class="mt-1.5 px-3.5 py-2.5 w-full text-[15px] bg-white border border-gray-300 rounded-lg">
241
236
  <option value="">Choose...</option>
242
- <option :for="choice in field.options.choices ?? []" value="{{ choice.value }}">{{ choice.label }}</option>
237
+ <option :for="choice in field.options.choices ?? []" :value="choice.value">{{ choice.label }}</option>
243
238
  </select>
244
239
  <div :else-if="field.type === 'radio'" class="mt-2 space-y-2">
245
240
  <label :for="choice in field.options.choices ?? []" class="flex gap-2.5 items-center text-[15px]">
246
- <input type="radio" name="{{ field.name }}" value="{{ choice.value }}" @change="values[field.name] = $event.target.value" class="h-4 w-4">
241
+ <input type="radio" :name="field.name" :value="choice.value" @change="setFieldValue(field.name, $event.target.value)" class="h-4 w-4">
247
242
  {{ choice.label }}
248
243
  </label>
249
244
  </div>
250
245
  <label :else-if="field.type === 'checkbox'" class="flex gap-2.5 items-center mt-2 text-[15px]">
251
- <input type="checkbox" @change="values[field.name] = $event.target.checked" class="h-4 w-4 rounded">
246
+ <input type="checkbox" @change="setFieldValue(field.name, $event.target.checked)" class="h-4 w-4 rounded">
252
247
  {{ field.options.placeholder ?? 'Yes' }}
253
248
  </label>
254
249
  {{-- A file field used to fall through to the text input below, so
255
250
  a person typed prose into what should have been a picker and
256
251
  the submission could never satisfy it. --}}
257
252
  <div :else-if="field.type === 'file'" class="mt-1.5">
258
- <input type="file" :required="field.required && !values[field.name]" :disabled="uploading[field.name]" accept="{{ acceptAttr(field) }}" @change="uploadFile(field, $event)" class="block file:mr-3 file:px-3.5 file:py-2 w-full text-[15px] file:font-medium file:text-[14px] file:bg-gray-100 file:border-0 file:rounded-lg">
253
+ <input type="file" :required="field.required && !values()[field.name]" :disabled="uploading[field.name]" :accept="acceptAttr(field)" @change="uploadFile(field, $event)" class="block file:mr-3 file:px-3.5 file:py-2 w-full text-[15px] file:font-medium file:text-[14px] file:bg-gray-100 file:border-0 file:rounded-lg">
259
254
  <p :if="uploading[field.name]" class="mt-1.5 text-[13px] text-gray-500">Uploading...</p>
260
255
  <p :else-if="attached[field.name]" class="mt-1.5 text-[13px] text-green-700">Attached {{ attached[field.name] }}</p>
261
256
  </div>
262
- <input :else type="{{ field.type === 'email' ? 'email' : (field.type === 'phone' ? 'tel' : (field.type === 'date' ? 'date' : (field.type === 'currency' ? 'number' : 'text'))) }}" :required="field.required" placeholder="{{ field.options.placeholder ?? '' }}" :step="field.type === 'currency' ? moneyInputStep(formCurrency()) : null" @input="setInput(field, $event.target.value)" class="mt-1.5 px-3.5 py-2.5 w-full text-[15px] border border-gray-300 rounded-lg">
257
+ <input :else :type="field.type === 'email' ? 'email' : (field.type === 'phone' ? 'tel' : (field.type === 'date' ? 'date' : (field.type === 'currency' ? 'number' : 'text')))" :required="field.required" :placeholder="field.options.placeholder ?? ''" :step="field.type === 'currency' ? moneyInputStep(formCurrency()) : null" @input="setInput(field, $event.target.value)" class="mt-1.5 px-3.5 py-2.5 w-full text-[15px] border border-gray-300 rounded-lg">
263
258
  <p :if="errors()[field.name]" class="mt-1.5 text-[13px] text-red-600">{{ errors()[field.name] }}</p>
264
259
  </template>
265
260
  </div>
266
261
  </template>
267
262
  </div>
268
263
 
269
- <input type="text" name="website" tabindex="-1" autocomplete="off" aria-hidden="true" class="hidden" @input="values._hp = $event.target.value">
264
+ <input type="text" name="website" tabindex="-1" autocomplete="off" aria-hidden="true" class="hidden" @input="setFieldValue('_hp', $event.target.value)">
270
265
 
271
266
  <button type="submit" :disabled="sending() || anyUploading()" class="mt-7 px-6 py-3 font-medium text-white bg-gray-900 rounded-lg disabled:opacity-60 transition-transform active:scale-[0.98]">
272
267
  {{ sending() ? 'Sending...' : (definition()?.submitLabel ?? 'Submit') }}
package/routes/forms.ts CHANGED
@@ -13,7 +13,7 @@ import { response, route } from '@stacksjs/router'
13
13
  * allowlist - deliberately not shipped until something needs it.)
14
14
  */
15
15
 
16
- async function siteIdForRequest(request: any): Promise<number | null> {
16
+ async function siteIdForRequest(request: any): Promise<number | null | undefined> {
17
17
  const options = sitesOptions()
18
18
  if (!options.enabled)
19
19
  return null
@@ -22,13 +22,16 @@ async function siteIdForRequest(request: any): Promise<number | null> {
22
22
  ? request.headers
23
23
  : new Headers(request.headers ?? {})
24
24
  const site = await resolveSiteByHost(requestHost(headers, options), undefined, options)
25
- return site?.id ?? null
25
+ return site?.id
26
26
  }
27
27
 
28
28
  route.get('/api/forms/{uuid}', async (request: any) => {
29
29
  const { loadFormByUuid, publicDefinition } = await import('@stacksjs/forms')
30
30
 
31
- const form = await loadFormByUuid(String(request.params?.uuid ?? request.param?.('uuid') ?? ''), await siteIdForRequest(request))
31
+ const siteId = await siteIdForRequest(request)
32
+ if (siteId === undefined)
33
+ return response.notFound('Form not found')
34
+ const form = await loadFormByUuid(String(request.params?.uuid ?? request.param?.('uuid') ?? ''), siteId)
32
35
  if (!form || form.status === 'draft')
33
36
  return response.notFound('Form not found')
34
37
 
@@ -50,7 +53,10 @@ route.get('/api/forms/{uuid}', async (request: any) => {
50
53
  route.post('/api/forms/{uuid}/uploads', async (request: any) => {
51
54
  const { checkUpload, fileFieldNamed, formUploadPrefix, loadFormByUuid, resolveUploadLimits } = await import('@stacksjs/forms')
52
55
 
53
- const form = await loadFormByUuid(String(request.params?.uuid ?? request.param?.('uuid') ?? ''), await siteIdForRequest(request))
56
+ const siteId = await siteIdForRequest(request)
57
+ if (siteId === undefined)
58
+ return response.notFound('Form not found')
59
+ const form = await loadFormByUuid(String(request.params?.uuid ?? request.param?.('uuid') ?? ''), siteId)
54
60
  if (!form || form.status === 'draft')
55
61
  return response.notFound('Form not found')
56
62
 
@@ -84,7 +90,10 @@ route.post('/api/forms/{uuid}/uploads', async (request: any) => {
84
90
  route.post('/api/forms/{uuid}/submissions', async (request: any) => {
85
91
  const { dispatchSubmissionNotifications, loadFormByUuid, submissionIdentity, submitForm } = await import('@stacksjs/forms')
86
92
 
87
- const form = await loadFormByUuid(String(request.params?.uuid ?? request.param?.('uuid') ?? ''), await siteIdForRequest(request))
93
+ const siteId = await siteIdForRequest(request)
94
+ if (siteId === undefined)
95
+ return response.notFound('Form not found')
96
+ const form = await loadFormByUuid(String(request.params?.uuid ?? request.param?.('uuid') ?? ''), siteId)
88
97
  if (!form)
89
98
  return response.notFound('Form not found')
90
99