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

Freegle / Iznik / 11495

09 May 2026 07:35AM UTC coverage: 69.06% (-3.8%) from 72.847%
11495

Pull #408

circleci

edwh
docs(migration): mark restartproject and repaircafewales as migrated (PR #408)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pull Request #408: feat(batch): migrate check_cgas, visualise, tn_sync + dry-run improvements

9127 of 10554 branches covered (86.48%)

Branch coverage included in aggregate %.

507 of 663 new or added lines in 16 files covered. (76.47%)

11902 existing lines in 138 files now uncovered.

101630 of 149824 relevant lines covered (67.83%)

19.56 hits per line

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

82.83
/iznik-nuxt3/components/MessageEditModal.vue
1
<template>
1✔
2
  <b-modal
1✔
3
    ref="modal"
1✔
4
    scrollable
1✔
5
    size="lg"
1✔
6
    hide-header
1✔
7
    body-class="edit-modal-body"
1✔
8
    @hidden="onModalHidden"
1✔
9
  >
10
    <template #default>
1✔
11
      <div v-if="message.location || message.item" class="edit-form">
1✔
12
        <!-- Type and Location row - at top, space-between -->
1✔
13
        <div class="form-card form-card-row-spaced">
1✔
14
          <div class="form-field">
1✔
15
            <label :for="uniqueId" class="form-label">Type</label>
1✔
16
            <b-form-select
1✔
17
              :id="uniqueId"
1✔
18
              v-model="type"
1✔
19
              :options="typeOptions"
1✔
20
              class="form-select"
1✔
21
            />
1✔
22
          </div>
1✔
23
          <div class="form-field">
1✔
24
            <label class="form-label">Location</label>
1✔
25
            <PostCode
1✔
26
              :find="false"
1✔
27
              :value="postcode?.name"
1✔
28
              @selected="postcodeSelect"
1✔
29
              @cleared="postcodeClear"
1✔
30
            />
1✔
31
          </div>
1✔
32
        </div>
1✔
33

34
        <!-- What is it? -->
1✔
35
        <div class="form-card">
1✔
36
          <label class="form-label">
1✔
37
            {{ type === 'Offer' ? 'What is it?' : 'What are you looking for?' }}
1✔
38
          </label>
1✔
39
          <PostItem
1✔
40
            :id="id"
1✔
41
            ref="item"
1✔
42
            v-model:edititem="edititem"
1✔
43
            :type="type"
1✔
44
            edit
1✔
45
            class="form-input"
1✔
46
          />
1✔
47
        </div>
1✔
48

49
        <!-- Description -->
1✔
50
        <div class="form-card">
1✔
51
          <label for="edit-description" class="form-label">
1✔
52
            Any details that might help?
53
          </label>
1✔
54
          <b-form-textarea
1✔
55
            id="edit-description"
1✔
56
            v-model="edittextbody"
1✔
57
            :placeholder="placeholder"
1✔
58
            class="form-textarea"
1✔
59
            rows="4"
1✔
60
            :state="triedToSave ? !isSaveButtonDisabled : null"
1!
61
          />
1✔
62
          <p class="invalid-feedback">
1✔
63
            Please provide either a description or a photo.
64
          </p>
1✔
65
        </div>
1✔
66

67
        <!-- Quantity and Deadline row -->
1✔
68
        <div class="form-card form-card-row-spaced">
1✔
69
          <div v-if="message.type === 'Offer'" class="form-field">
1✔
70
            <label class="form-label">How many?</label>
1✔
71
            <NumberIncrementDecrement
1✔
72
              v-model="availablenow"
1✔
73
              :min="1"
1✔
74
              :max="99"
1✔
75
              class="quantity-control"
1✔
76
            />
1✔
77
          </div>
1✔
78
          <div class="form-field form-field-date">
1✔
79
            <label for="edit-deadline" class="form-label">Deadline</label>
1✔
80
            <b-input
1✔
81
              id="edit-deadline"
1✔
82
              v-model="deadline"
1✔
83
              class="form-date"
1✔
84
              type="date"
1✔
85
              :min="today"
1✔
86
              :max="defaultDeadline"
1✔
87
            />
1✔
88
          </div>
1✔
89
        </div>
1✔
90

91
        <!-- Photo section - at the bottom, compact -->
1✔
92
        <div class="form-card photo-card">
1✔
93
          <label class="form-label">Photos</label>
1✔
94
          <draggable
1✔
95
            v-model="attachments"
1✔
96
            class="photo-grid"
1✔
97
            :item-key="(el) => `edit-photo-${el.id}`"
1✔
98
            :animation="150"
1✔
99
            ghost-class="ghost"
1✔
100
            @start="dragging = true"
1✔
101
            @end="dragging = false"
1✔
102
          >
103
            <template #item="{ element }">
1✔
104
              <PostPhoto
1✔
105
                :id="element.id"
1✔
106
                :key="element.id"
1✔
107
                :path="element.path"
1✔
108
                :paththumb="element.paththumb"
1✔
109
                :ouruid="element.ouruid"
1✔
110
                :externalmods="element.externalmods"
1✔
111
                class="photo-item"
1✔
112
                @remove="removePhoto"
1✔
113
              />
1✔
114
            </template>
115
            <template #footer>
1✔
116
              <div v-if="!dragging" class="photo-add">
1✔
117
                <OurUploader
1✔
118
                  v-model="attachments"
1✔
119
                  type="Message"
1✔
120
                  multiple
1✔
121
                  compact
1✔
122
                />
1✔
123
              </div>
1✔
124
            </template>
125
          </draggable>
1✔
126
        </div>
1✔
127
      </div>
1✔
128

129
      <!-- Fallback for messages without location -->
1✔
130
      <div v-else class="form-card">
1✔
131
        <label class="form-label">Subject</label>
1✔
132
        <b-form-input v-model="message.subject" />
1✔
133
      </div>
1✔
134
    </template>
135
    <template #footer>
1✔
136
      <b-button variant="white" :disabled="uploadingPhoto" @click="hide">
1✔
137
        Cancel
138
      </b-button>
1✔
139
      <SpinButton
1✔
140
        variant="primary"
1✔
141
        :disabled="uploadingPhoto || isSaveButtonDisabled"
1✔
142
        icon-name="save"
1✔
143
        label="Save"
1✔
144
        @handle="save"
1✔
145
      />
1✔
146
    </template>
147
  </b-modal>
1✔
148
</template>
149

150
<script setup>
151
import { ref, computed, defineAsyncComponent, toRaw } from 'vue'
1✔
152
import draggable from 'vuedraggable'
1✔
153
import NumberIncrementDecrement from './NumberIncrementDecrement'
1✔
154
import PostPhoto from './PostPhoto.vue'
1✔
155
import { useMessageStore } from '~/stores/message'
1✔
156
import { useComposeStore } from '~/stores/compose'
1✔
157
import { useGroupStore } from '~/stores/group'
1✔
158
import { uid } from '~/composables/useId'
1✔
159
import PostCode from '~/components/PostCode'
1✔
160
import { useOurModal } from '~/composables/useOurModal'
1✔
161
import { MESSAGE_EXPIRE_TIME } from '~/constants'
1✔
162

163
const OurUploader = defineAsyncComponent(() =>
1✔
UNCOV
164
  import('~/components/OurUploader')
×
165
)
1✔
166
const PostItem = defineAsyncComponent(() => import('./PostItem'))
1✔
167

168
const props = defineProps({
1✔
169
  id: {
170
    type: Number,
171
    required: true,
172
  },
173
})
174

175
const emit = defineEmits(['hidden'])
1✔
176

177
const messageStore = useMessageStore()
1✔
178
const composeStore = useComposeStore()
1✔
179
const groupStore = useGroupStore()
1✔
180

181
const { modal, hide } = useOurModal()
1✔
182

183
// Message was fetched by parent.
1✔
184
const message = toRaw(messageStore.byId(props.id))
1✔
185
const textbody = message.textbody
1✔
186
// Use item name from API, or extract from subject if not available.
1✔
187
// Subject format: "OFFER: Item Name (Location)" or "Offer: Item Name"
1✔
188
const itemName =
1✔
189
  message.item?.name ||
1✔
190
  (() => {
7✔
191
    const match = message.subject?.match(
7✔
192
      /^(?:offer|wanted):\s*(.+?)(?:\s*\(.*\))?\s*$/i
7✔
193
    )
7✔
194
    return match ? match[1].trim() : ''
7✔
195
  })()
7✔
196
const attachments = ref(message.attachments || [])
1!
197
const dragging = ref(false)
1✔
198
const triedToSave = ref(false)
1✔
199
const item = ref(null)
1✔
200

201
const defaultDeadline = new Date(
1✔
202
  Date.now() + MESSAGE_EXPIRE_TIME * 24 * 60 * 60 * 1000
1✔
203
)
1✔
204
  .toISOString()
1✔
205
  .substring(0, 10)
1✔
206

207
const today = computed(() => {
1✔
208
  return new Date(Date.now()).toISOString().substring(0, 10)
34✔
209
})
34✔
210

211
const edittextbody = ref(textbody)
1✔
212
const availablenow = ref(message.availablenow)
1✔
213
const deadline = ref(
1✔
214
  message.deadline
1✔
215
    ? new Date(message.deadline).toISOString().substring(0, 10)
1!
216
    : null
1✔
217
)
1✔
218
const type = ref(message.type)
1✔
219
const edititem = ref(itemName)
1✔
220
const postcode = ref(message.location)
1✔
221

222
const uniqueId = computed(() => {
1✔
223
  return uid('posttype-')
34✔
224
})
34✔
225

226
const uploadingPhoto = computed(() => {
1✔
227
  return composeStore?.uploading
35✔
228
})
35✔
229

230
const placeholder = computed(() => {
1✔
231
  return message && type.value === 'Offer'
34✔
232
    ? "e.g. colour, condition, size, whether it's working..."
34✔
233
    : 'Size, colour, any specific requirements...'
34✔
234
})
34✔
235

236
const groupid = computed(() => {
1✔
237
  return message?.groups?.[0]?.groupid
34✔
238
})
34✔
239

240
const group = computed(() => {
1✔
241
  return groupStore?.get(groupid.value)
34✔
242
})
34✔
243

244
const typeOptions = computed(() => {
1✔
245
  return [
34✔
246
    {
34✔
247
      value: 'Offer',
34✔
248
      text: group.value?.settings?.keywords?.offer
34✔
249
        ? group.value.settings.keywords.offer
34✔
250
        : 'OFFER',
34!
251
    },
34✔
252
    {
34✔
253
      value: 'Wanted',
34✔
254
      text: group.value?.settings?.keywords?.wanted
34✔
255
        ? group.value.settings.keywords.wanted
34✔
256
        : 'WANTED',
34!
257
    },
34✔
258
  ]
34✔
259
})
34✔
260

261
const isSaveButtonDisabled = computed(() => {
1✔
262
  return !edittextbody.value && !attachments.value?.length
34✔
263
})
34✔
264

UNCOV
265
async function save(finishSpinner) {
×
UNCOV
266
  triedToSave.value = true
×
267

UNCOV
268
  if (edititem.value && (edittextbody.value || attachments.value?.length)) {
×
UNCOV
269
    const attids = []
×
270

UNCOV
271
    if (attachments.value?.length) {
×
272
      for (const att of attachments.value) {
×
273
        attids.push(att.id)
×
UNCOV
274
      }
×
UNCOV
275
    }
×
276

UNCOV
277
    const params = {
×
UNCOV
278
      id: props.id,
×
UNCOV
279
      msgtype: type.value,
×
UNCOV
280
      item: edititem.value,
×
UNCOV
281
      location: postcode.value?.name,
×
UNCOV
282
      textbody: edittextbody.value,
×
UNCOV
283
      attachments: attids,
×
UNCOV
284
      availablenow: availablenow.value,
×
UNCOV
285
      availableinitially: availablenow.value,
×
UNCOV
286
      deadline:
×
UNCOV
287
        deadline.value && deadline.value > '1970-01-01' ? deadline.value : null,
×
UNCOV
288
    }
×
289

UNCOV
290
    // Emit 'hidden' synchronously before patching. The patch() call removes then
×
UNCOV
291
    // re-adds the message in the store, which unmounts/remounts the v-if block in
×
UNCOV
292
    // MyMessage.vue containing this modal. We must set showEditModal=false in the
×
UNCOV
293
    // parent before that happens, otherwise useOurModal's onMounted reopens the modal.
×
UNCOV
294
    // We can't rely on hide()'s async b-modal transition completing in time.
×
UNCOV
295
    finishSpinner()
×
UNCOV
296
    emit('hidden')
×
UNCOV
297
    await messageStore.patch(params)
×
UNCOV
298
  }
×
UNCOV
299
}
×
300

301
function removePhoto(id) {
×
302
  attachments.value = attachments.value.filter((item) => {
×
UNCOV
303
    return item.id !== id
×
UNCOV
304
  })
×
UNCOV
305
}
×
306

UNCOV
307
function postcodeSelect(pc) {
×
UNCOV
308
  postcode.value = pc
×
UNCOV
309
}
×
310

311
function postcodeClear() {
×
312
  postcode.value = null
×
UNCOV
313
}
×
314

UNCOV
315
function onModalHidden() {
×
UNCOV
316
  emit('hidden')
×
UNCOV
317
}
×
318
</script>
319

320
<style scoped lang="scss">
321
@import 'bootstrap/scss/functions';
322
@import 'bootstrap/scss/variables';
323
@import 'bootstrap/scss/mixins/_breakpoints';
324
@import 'assets/css/_color-vars.scss';
325

326
/* Modal body background and height constraint for sticky ads */
327
:deep(.edit-modal-body) {
328
  background: $color-gray--lighter;
329
  padding: 0.5rem;
330
  max-height: calc(100vh - 200px) !important;
331

332
  @include media-breakpoint-up(md) {
333
    padding: 0.75rem;
334
    max-height: calc(100vh - 350px) !important;
335
  }
336
}
337

338
/* Form layout */
339
.edit-form {
340
  display: flex;
341
  flex-direction: column;
342
  gap: 0.5rem;
343
}
344

345
/* Card style for each section */
346
.form-card {
347
  background: white;
348
  padding: 0.875rem;
349
  box-shadow: var(--shadow-sm);
350
}
351

352
/* Row layout for combined fields - space between */
353
.form-card-row-spaced {
354
  display: flex;
355
  flex-direction: column;
356
  gap: 0.75rem;
357

358
  @include media-breakpoint-up(sm) {
359
    flex-direction: row;
360
    align-items: flex-end;
361
    justify-content: space-between;
362
    gap: 1rem;
363
  }
364
}
365

366
.form-field {
367
  flex: 0 0 auto;
368
  min-width: 140px;
369
}
370

371
.form-field-date {
372
  flex: 0 0 auto;
373
  width: 160px;
374
}
375

376
/* Conversational label style */
377
.form-label {
378
  display: block;
379
  font-size: 0.9rem;
380
  font-weight: 500;
381
  color: var(--color-gray-700);
382
  margin-bottom: 0.375rem;
383
}
384

385
/* Input styling */
386
.form-input {
387
  :deep(input) {
388
    font-size: 1rem;
389
    padding: 0.5rem 0.75rem;
390
    border: 2px solid $color-gray-3;
391
    transition: border-color var(--transition-normal);
392

393
    &:focus {
394
      border-color: $color-green-background;
395
      box-shadow: 0 0 0 2px rgba($color-green-background, 0.1);
396
    }
397
  }
398

399
  :deep(label) {
400
    display: none;
401
  }
402
}
403

404
.form-textarea {
405
  font-size: 0.95rem;
406
  padding: 0.5rem 0.75rem;
407
  border: 2px solid $color-gray-3;
408
  resize: vertical;
409
  min-height: 80px;
410
  transition: border-color var(--transition-normal);
411

412
  &:focus {
413
    border-color: $color-green-background;
414
    box-shadow: 0 0 0 2px rgba($color-green-background, 0.1);
415
  }
416
}
417

418
.form-select {
419
  font-size: 1rem;
420
  padding: 0.5rem 0.75rem;
421
  border: 2px solid $color-gray-3;
422
  text-transform: uppercase;
423
  transition: border-color var(--transition-normal);
424
  min-height: 42px;
425
  width: 100%;
426

427
  &:focus {
428
    border-color: $color-green-background;
429
    box-shadow: 0 0 0 2px rgba($color-green-background, 0.1);
430
  }
431
}
432

433
.form-date {
434
  font-size: 1rem;
435
  padding: 0.5rem 0.75rem;
436
  border: 2px solid $color-gray-3;
437
  transition: border-color var(--transition-normal);
438
  min-height: 42px;
439
  width: 100%;
440

441
  &:focus {
442
    border-color: $color-green-background;
443
    box-shadow: 0 0 0 2px rgba($color-green-background, 0.1);
444
  }
445
}
446

447
.quantity-control {
448
  :deep(label) {
449
    display: none;
450
  }
451

452
  :deep(.input-group) {
453
    min-height: 42px;
454
  }
455

456
  :deep(input) {
457
    min-height: 42px;
458
  }
459

460
  :deep(.btn) {
461
    min-height: 42px;
462
  }
463
}
464

465
/* PostCode styling */
466
:deep(.autocomplete-wrap) {
467
  border: 2px solid $color-gray-3 !important;
468
  min-height: 42px;
469

470
  input {
471
    font-size: 1rem !important;
472
    padding: 0.5rem 0.75rem !important;
473
    border: none !important;
474
    min-height: 38px !important;
475
  }
476

477
  &:focus-within {
478
    border-color: $color-green-background !important;
479
    box-shadow: 0 0 0 2px rgba($color-green-background, 0.1);
480
  }
481
}
482

483
:deep(.postcode-input-wrapper) {
484
  min-width: 150px;
485
}
486

487
:deep(.postcode-input-wrapper .pcinp) {
488
  padding-right: 2.5rem;
489
}
490

491
/* Photo section - compact grid */
492
.photo-card {
493
  padding: 0.75rem;
494
}
495

496
.photo-grid {
497
  display: flex;
498
  flex-wrap: wrap;
499
  gap: 0.5rem;
500
  align-items: flex-start;
501
}
502

503
.photo-item {
504
  width: 100px;
505
  height: 100px;
506
  margin: 0 !important;
507

508
  :deep(.container) {
509
    width: 100px !important;
510
    height: 100px !important;
511
    padding: 0 !important;
512
    margin: 0 !important;
513
  }
514

515
  :deep(.image-wrapper) {
516
    width: 100px;
517
    height: 100px;
518

519
    img,
520
    picture {
521
      width: 100px !important;
522
      height: 100px !important;
523
      object-fit: cover;
524
    }
525
  }
526

527
  :deep(.square) {
528
    width: 100px !important;
529
    height: 100px !important;
530
    min-width: 100px !important;
531
    min-height: 100px !important;
532
    max-width: 100px !important;
533
    max-height: 100px !important;
534
  }
535
}
536

537
.ghost {
538
  opacity: 0.5;
539
}
540

541
.photo-add {
542
  width: 100px;
543
  height: 100px;
544
  display: flex;
545
  align-items: center;
546
  justify-content: center;
547
  border: 2px dashed $color-gray-3;
548
  border-radius: var(--radius-md, 0.5rem);
549
  background: var(--color-gray-50);
550

551
  :deep(.wrapper) {
552
    width: 100% !important;
553
    height: 100% !important;
554
    min-height: unset !important;
555
    display: flex;
556
    flex-direction: column;
557
    align-items: center;
558
    justify-content: center;
559
    padding: 0.5rem;
560
  }
561

562
  :deep(.camera) {
563
    font-size: 1.5rem !important;
564
    margin-bottom: 0.25rem;
565
  }
566

567
  :deep(.btn) {
568
    padding: 0.25rem 0.5rem !important;
569
    font-size: 0.7rem !important;
570
  }
571

572
  :deep(.d-flex) {
573
    flex-direction: column;
574
    align-items: center;
575
    gap: 0.25rem;
576
  }
577
}
578
</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