• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Freegle / iznik-nuxt3 / 421d1208-e899-4cd0-a60f-0c0b99f0a3b7

25 Dec 2025 10:03PM UTC coverage: 42.35%. First build
421d1208-e899-4cd0-a60f-0c0b99f0a3b7

Pull #131

circleci

edwh
Fix navigation timing in reply flow tests

- Add waitForLoadState in clickReplyButton to prevent context destruction
- Wait for page to stabilize after reload and goBack before interacting
- Use safer pattern: wait for button to be attached before checking count
Pull Request #131: Add reply flow state machine and comprehensive Playwright tests

3376 of 8043 branches covered (41.97%)

Branch coverage included in aggregate %.

16 of 17 new or added lines in 1 file covered. (94.12%)

1756 of 4075 relevant lines covered (43.09%)

59.6 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

75.0
/components/MessageReplySection.vue
1
<template>
2
  <div>
3
    <div v-if="!fromme" class="grey p-2">
66!
4
      <EmailValidator
5
        v-if="!me"
66✔
6
        ref="emailValidatorRef"
7
        v-model:email="stateMachine.email.value"
45✔
8
        v-model:valid="stateMachine.emailValid.value"
45✔
9
        size="lg"
10
        label="Your email address:"
11
        class="test-email-reply-validator"
12
      />
13
      <NoticeMessage
14
        v-if="milesaway > faraway && message.type === 'Offer'"
132!
15
        variant="danger"
16
        class="mt-2 mb-1"
17
      >
18
        This item is {{ milesaway }} miles away. Before replying, are you sure
19
        you can collect from there?
20
      </NoticeMessage>
167!
21
      <NoticeMessage v-if="me?.deleted" variant="danger" class="mt-2 mb-1">
22
        You can't reply until you've decided whether to restore your account.
23
      </NoticeMessage>
×
24
      <div v-else>
25
        <VeeForm ref="form">
26
          <b-form-group
27
            class="flex-grow-1"
28
            label="Your reply:"
29
            :label-for="'replytomessage-' + message.id"
30
            :description="
31
              message.type === 'Offer'
32
                ? 'Explain why you\'d like it.  It\'s not always first come first served.  If appropriate, ask if it\'s working. Always be polite.'
33
                : 'Can you help?  If you have what they\'re looking for, let them know.'
34
            "
35
          >
36
            <div class="d-flex flex-wrap">
37
              <div v-if="message.deliverypossible" class="mb-2 mr-2">
247✔
38
                <b-badge
39
                  v-b-tooltip="
40
                    'They have said they may be able to deliver.  No guarantees - it needs to be convenient for them - but you can ask.'
41
                  "
42
                  variant="info"
43
                  ><v-icon icon="info-circle" /> Delivery may be
159✔
44
                  possible</b-badge
45
                >
46
              </div>
47
              <MessageDeadline :id="id" class="mb-2" />
48
            </div>
49
            <Field
50
              v-if="message.type == 'Offer'"
247!
51
              :id="'replytomessage-' + message.id"
52
              v-model="stateMachine.replyText.value"
268✔
53
              name="reply"
54
              :rules="validateReply"
55
              as="textarea"
56
              rows="3"
57
              max-rows="8"
58
              class="border border-success w-100"
59
              @input="stateMachine.startTyping"
60
            />
61
            <Field
62
              v-if="message.type == 'Wanted'"
247!
63
              :id="'replytomessage-' + message.id"
64
              v-model="stateMachine.replyText.value"
×
65
              name="reply"
66
              :rules="validateReply"
67
              as="textarea"
68
              rows="3"
69
              max-rows="8"
70
              class="flex-grow-1 w-100"
71
              @input="stateMachine.startTyping"
72
            />
73
          </b-form-group>
74
          <ErrorMessage name="reply" class="text-danger font-weight-bold" />
75
          <b-form-group
76
            v-if="message.type == 'Offer'"
230!
77
            class="mt-1"
78
            label="When could you collect?"
79
            :label-for="'replytomessage2-' + message.id"
80
            description="Suggest days and times you could collect if you're chosen.  Your plans might change but this speeds up making arrangements."
81
          >
82
            <Field
83
              :id="'replytomessage2-' + message.id"
84
              v-model="stateMachine.collectText.value"
263✔
85
              name="collect"
86
              :rules="validateCollect"
87
              class="border border-success w-100"
88
              as="textarea"
89
              rows="2"
90
              max-rows="2"
91
            />
92
          </b-form-group>
93
          <ErrorMessage name="collect" class="text-danger font-weight-bold" />
94
        </VeeForm>
95
        <p v-if="me && !alreadyAMember" class="text--small text-muted">
66✔
96
          You're not yet a member of this community; we'll join you. Change
97
          emails or leave communities from
98
          <em>Settings</em>.
99
        </p>
100
      </div>
101
      <div v-if="!me">
102
        <NewFreegler class="mt-2" />
103
      </div>
104
      <NoticeMessage
105
        v-if="stateMachine.error.value"
66!
106
        variant="danger"
107
        class="mt-2"
108
      >
109
        {{ stateMachine.error.value }}
110
        <b-button
111
          variant="link"
112
          size="sm"
113
          class="p-0 ml-2"
114
          @click="stateMachine.retry"
115
        >
116
          Try again
117
        </b-button>
87!
118
      </NoticeMessage>
119
    </div>
120
    <hr />
121
    <div class="d-flex justify-content-between">
122
      <div class="pr-2 w-50">
123
        <b-button variant="secondary" size="lg" block @click="close">
124
          Cancel
125
        </b-button>
240!
126
      </div>
127
      <div
128
        v-if="!fromme && !me?.deleted"
101✔
129
        class="pl-2 w-50 justify-content-end d-flex"
130
      >
131
        <SpinButton
132
          variant="primary"
133
          size="lg"
134
          done-icon=""
135
          icon-name="angle-double-right"
136
          :disabled="
137
            !stateMachine.canSend.value || stateMachine.isProcessing.value
138
          "
139
          iconlast
140
          @handle="handleSend"
141
        >
142
          Send <span class="d-none d-md-inline">your</span> reply
55✔
143
        </SpinButton>
144
      </div>
145
    </div>
146
    <b-modal
147
      v-if="stateMachine.showWelcomeModal.value"
66✔
148
      id="newUserModal"
149
      ref="newUserModal"
150
      scrollable
151
      ok-only
152
      ok-title="Close and Continue"
153
      @ok="handleNewUserModalOk"
154
    >
155
      <template #title>
156
        <h2>Welcome to Freegle!</h2>
157
      </template>
158
      <NewUserInfo :password="stateMachine.newUserPassword.value" />
159
    </b-modal>
160
    <span ref="breakpoint" class="d-inline d-sm-none" />
161
    <div class="d-none">
162
      <ChatButton ref="replyToPostChatButton" :userid="replyToUser" />
163
    </div>
164
  </div>
165
</template>
166
<script setup>
167
import { Form as VeeForm, Field, ErrorMessage } from 'vee-validate'
168
import {
169
  defineAsyncComponent,
170
  ref,
171
  computed,
172
  watch,
173
  nextTick,
174
  onMounted,
175
} from 'vue'
176
import { useRoute } from 'vue-router'
177
import { useMessageStore } from '~/stores/message'
178
import { milesAway } from '~/composables/useDistance'
179
import { useMe } from '~/composables/useMe'
180
import {
181
  useReplyStateMachine,
182
  ReplyState,
183
} from '~/composables/useReplyStateMachine'
184
import { action } from '~/composables/useClientLog'
185
import EmailValidator from '~/components/EmailValidator'
186
import NewUserInfo from '~/components/NewUserInfo'
187
import ChatButton from '~/components/ChatButton'
188
import SpinButton from '~/components/SpinButton.vue'
189
import NoticeMessage from '~/components/NoticeMessage'
190
import MessageDeadline from '~/components/MessageDeadline'
191
import { FAR_AWAY } from '~/constants'
192

193
const route = useRoute()
21✔
194

195
const NewFreegler = defineAsyncComponent(() =>
196
  import('~/components/NewFreegler')
197
)
198

199
const props = defineProps({
200
  id: {
201
    type: Number,
202
    required: true,
203
  },
204
  messageOverride: {
205
    type: Object,
206
    required: false,
207
    default: null,
208
  },
209
})
210

211
const emit = defineEmits(['close', 'sent'])
212

213
const faraway = FAR_AWAY
214

215
const messageStore = useMessageStore()
216
const { me, myid, myGroups } = useMe()
217

218
// Initialize state machine
219
const stateMachine = useReplyStateMachine(props.id)
220

221
// References
222
const form = ref(null)
223
const newUserModal = ref(null)
224
const replyToPostChatButton = ref(null)
225
const breakpoint = ref(null)
226
const emailValidatorRef = ref(null)
227

228
// Fetch the message data
229
await messageStore.fetch(props.id)
21✔
230

231
const message = computed(() => {
21✔
232
  return messageStore?.byId(props.id)
33!
233
})
234

235
const milesaway = computed(() => {
236
  return milesAway(me?.lat, me?.lng, message.value?.lat, message.value?.lng)
33!
237
})
238

239
const fromme = computed(() => {
240
  return message.value?.fromuser === myid.value
40!
241
})
242

243
const alreadyAMember = computed(() => {
244
  let found = false
38✔
245

246
  if (message.value?.groups) {
38✔
247
    for (const messageGroup of message.value.groups) {
35✔
248
      Object.keys(myGroups.value).forEach((key) => {
35✔
249
        const group = myGroups.value[key]
15✔
250

251
        if (messageGroup.groupid === group.id) {
15✔
252
          found = true
253
        }
254
      })
255
    }
256
  }
257

258
  return found
38✔
259
})
260

261
const replyToUser = computed(() => {
262
  return message.value?.fromuser
33!
263
})
264

265
// Watch for login state changes to resume authentication flow
266
watch(me, (newVal, oldVal) => {
21✔
267
  console.log('[MessageReplySection] Login state changed', {
19✔
268
    newVal: !!newVal,
269
    oldVal: !!oldVal,
270
  })
271
  if (
272
    !oldVal &&
38✔
273
    newVal &&
274
    stateMachine.state.value === ReplyState.AUTHENTICATING
275
  ) {
276
    console.log(
277
      '[MessageReplySection] User logged in during authentication - resuming'
278
    )
279
    stateMachine.onLoginSuccess()
280
  }
281
})
282

283
// Watch for chat button ref becoming available
284
watch(replyToPostChatButton, (newVal) => {
285
  if (newVal) {
21✔
286
    console.log('[MessageReplySection] Chat button ref now available')
287
    stateMachine.setRefs({ chatButton: newVal })
288
  }
289
})
290

291
// Watch for form ref
292
watch(form, (newVal) => {
293
  if (newVal) {
21✔
294
    console.log('[MessageReplySection] Form ref now available')
295
    stateMachine.setRefs({ form: newVal })
296
  }
297
})
298

299
// Determine reply source from route for analytics.
300
function getReplySource() {
21✔
301
  const path = route.path
21✔
302
  const query = route.query || {}
21!
303

304
  // Check for email digest/newsletter links.
305
  if (query.src === 'digest' || query.utm_source === 'digest') {
21!
306
    return 'email_digest'
307
  }
308
  if (query.src === 'newsletter' || query.utm_source === 'newsletter') {
21!
309
    return 'email_newsletter'
310
  }
311
  if (query.src || query.utm_source) {
21!
312
    return `email_${query.src || query.utm_source}`
×
313
  }
314

315
  // Determine from route path.
316
  if (path.startsWith('/browse')) {
21!
317
    return 'browse_page'
318
  }
319
  if (path.startsWith('/explore')) {
21✔
320
    return 'explore_page'
321
  }
322
  if (path.match(/^\/message\/\d+/)) {
15!
323
    return 'message_page'
324
  }
325
  if (path.startsWith('/find')) {
×
326
    return 'find_page'
327
  }
328

329
  return 'unknown'
330
}
331

332
// Set refs on mount
333
onMounted(() => {
21✔
334
  console.log('[MessageReplySection] Component mounted, setting refs')
21✔
335
  stateMachine.setRefs({
336
    form: form.value,
337
    chatButton: replyToPostChatButton.value,
338
    emailValidator: emailValidatorRef.value,
339
  })
340

341
  // Set and log the reply source for analytics.
342
  const replySource = getReplySource()
21✔
343
  stateMachine.setReplySource(replySource)
21✔
344
  action('reply_section_viewed', {
345
    message_id: props.id,
346
    reply_source: replySource,
347
    message_type: message.value?.type,
21!
348
    is_logged_in: !!me.value,
349
    route_path: route.path,
350
    route_query: JSON.stringify(route.query || {}),
21!
351
  })
352
})
353

354
// Watch for state machine completion
355
watch(
356
  () => stateMachine.isComplete.value,
357
  (isComplete) => {
358
    if (isComplete) {
7✔
359
      console.log('[MessageReplySection] Reply flow completed')
360
      sent()
361
    }
362
  }
363
)
364

365
// Watch for welcome modal state to show it
366
watch(
367
  () => stateMachine.showWelcomeModal.value,
368
  async (showModal) => {
2✔
369
    if (showModal) {
2✔
370
      console.log('[MessageReplySection] Showing welcome modal')
371
      await nextTick()
4✔
372
      newUserModal.value?.show()
373
    }
374
  }
375
)
376

377
function validateCollect(value) {
69✔
378
  if (value && value.trim()) {
69✔
379
    return true
380
  }
381
  return 'Please suggest some days and times when you could collect.'
382
}
383

384
function validateReply(value) {
78✔
385
  if (!value?.trim()) {
78!
386
    return 'Please fill out your reply.'
387
  }
388

389
  if (
390
    message.value?.type === 'Offer' &&
391
    value &&
392
    value.length <= 35 &&
393
    value.toLowerCase().includes('still available')
394
  ) {
395
    return (
396
      "You don't need to ask if things are still available. Just write whatever you " +
397
      "would have said next - explain why you'd like it and when you could collect."
398
    )
399
  }
400

401
  return true
402
}
403

404
async function handleSend(callback) {
12✔
405
  console.log('[MessageReplySection] handleSend called')
12✔
406
  // Ensure refs are set before submitting
407
  stateMachine.setRefs({
408
    form: form.value,
409
    chatButton: replyToPostChatButton.value,
410
    emailValidator: emailValidatorRef.value,
411
  })
412
  await stateMachine.submit(callback)
413
}
414

415
function handleNewUserModalOk() {
×
NEW
416
  console.log('[MessageReplySection] New user modal closed')
×
417
  stateMachine.closeWelcomeModal()
418
}
419

420
function close() {
×
421
  emit('close')
×
422
}
423

424
function sent() {
7✔
425
  emit('sent')
7✔
426
}
427
</script>
428
<style scoped lang="scss">
429
.grey {
430
  background-color: $color-gray--lighter;
431
}
432

433
:deep(.phone) {
434
  border: 2px solid $color-gray--normal !important;
435
}
436

437
.nobot {
438
  margin-bottom: 0 !important;
439
}
440
</style>
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc