@bcrs-shared-components/base-address 2.0.40 → 2.0.42

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/BaseAddress.vue CHANGED
@@ -1,575 +1,580 @@
1
- //
2
- // Copyright © 2020 Province of British Columbia
3
- //
4
- // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
5
- // the License. You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
10
- // an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
11
- // specific language governing permissions and limitations under the License.
12
- //
13
-
14
- <template>
15
- <div class="base-address">
16
- <!-- Display fields -->
17
- <v-expand-transition>
18
- <div
19
- v-if="!editing"
20
- class="address-block"
21
- >
22
- <div class="address-block__info pre-line">
23
- <div class="address-block__info-row street-address">
24
- {{ addressLocal.streetAddress }}
25
- </div>
26
-
27
- <div class="address-block__info-row street-address-additional">
28
- {{ addressLocal.streetAddressAdditional }}
29
- </div>
30
-
31
- <div class="address-block__info-row">
32
- <span class="address-city">{{ addressLocal.addressCity }}</span>
33
-
34
- <template v-if="addressLocal.addressRegion">
35
- <span class="address-region">&nbsp;{{ addressLocal.addressRegion }}</span>
36
- </template>
37
-
38
- <template v-if="addressLocal.postalCode">
39
- <span class="postal-code">&nbsp;{{ addressLocal.postalCode }}</span>
40
- </template>
41
- </div>
42
-
43
- <div class="address-block__info-row address-country">
44
- {{ getCountryName(addressCountry) }}
45
- </div>
46
-
47
- <template v-if="addressLocal.deliveryInstructions">
48
- <div class="address-block__info-row delivery-instructions mt-5 font-italic">
49
- {{ addressLocal.deliveryInstructions }}
50
- </div>
51
- </template>
52
- </div>
53
- </div>
54
- </v-expand-transition>
55
-
56
- <!-- Edit fields -->
57
- <v-expand-transition>
58
- <v-form
59
- v-if="editing"
60
- ref="addressForm"
61
- name="address-form"
62
- lazy-validation
63
- >
64
- <div class="form__row">
65
- <!-- NB1: AddressComplete needs to be enabled each time user clicks in this search field.
66
- NB2: Only process first keypress -- assumes if user moves between instances of this
67
- component then they are using the mouse (and thus, clicking). -->
68
- <v-text-field
69
- :id="streetAddressId"
70
- v-model="addressLocal.streetAddress"
71
- autocomplete="chrome-off"
72
- :name="Math.random()"
73
- filled
74
- class="street-address"
75
- :hint="streetAddressHint"
76
- persistent-hint
77
- :label="streetAddressLabel"
78
- :rules="[...rules.streetAddress, ...spaceRules]"
79
- @keypress.once="enableAddressComplete()"
80
- @click="enableAddressComplete()"
81
- />
82
- </div>
83
- <div class="form__row">
84
- <v-textarea
85
- v-model="addressLocal.streetAddressAdditional"
86
- autocomplete="chrome-off"
87
- :name="Math.random()"
88
- auto-grow
89
- filled
90
- class="street-address-additional"
91
- :label="streetAddressAdditionalLabel"
92
- rows="1"
93
- :rules="[...rules.streetAddressAdditional, ...spaceRules]"
94
- />
95
- </div>
96
- <div class="form__row three-column">
97
- <v-text-field
98
- v-model="addressLocal.addressCity"
99
- filled
100
- class="item address-city"
101
- :label="addressCityLabel"
102
- :rules="[...rules.addressCity, ...spaceRules]"
103
- />
104
- <v-select
105
- v-if="useCountryRegions(addressCountry)"
106
- v-model="addressLocal.addressRegion"
107
- filled
108
- class="item address-region"
109
- :menu-props="{maxHeight:'40rem'}"
110
- :label="addressRegionLabel"
111
- item-text="name"
112
- item-value="short"
113
- :items="isAddressCountryCanadaAndExcludeBc ? getCanadaRegionsExcludeBC() :
114
- getCountryRegions(addressCountry)"
115
- :rules="[...rules.addressRegion, ...spaceRules]"
116
- />
117
- <v-text-field
118
- v-else
119
- v-model="addressLocal.addressRegion"
120
- filled
121
- class="item address-region"
122
- :label="addressRegionLabel"
123
- :rules="[...rules.addressRegion, ...spaceRules]"
124
- />
125
- <v-text-field
126
- v-model="addressLocal.postalCode"
127
- filled
128
- class="item postal-code"
129
- :label="postalCodeLabel"
130
- :rules="[...rules.postalCode, ...spaceRules]"
131
- />
132
- </div>
133
- <div class="form__row">
134
- <v-select
135
- v-model="addressLocal.addressCountry"
136
- filled
137
- class="address-country"
138
- :label="addressCountryLabel"
139
- menu-props="auto"
140
- item-text="name"
141
- item-value="code"
142
- :items="getCountries()"
143
- :rules="[...rules.addressCountry, ...spaceRules]"
144
- @change="resetRegion()"
145
- />
146
- <!-- special field to select AddressComplete country, separate from our model field -->
147
- <input
148
- :id="addressCountryId"
149
- type="hidden"
150
- :value="addressCountry"
151
- >
152
- </div>
153
- <div class="form__row">
154
- <v-textarea
155
- v-model="addressLocal.deliveryInstructions"
156
- auto-grow
157
- filled
158
- class="delivery-instructions"
159
- :label="deliveryInstructionsLabel"
160
- rows="2"
161
- :rules="[...rules.deliveryInstructions, ...spaceRules]"
162
- />
163
- </div>
164
- </v-form>
165
- </v-expand-transition>
166
- </div>
167
- </template>
168
-
169
- <script lang="ts">
170
- import Vue from 'vue'
171
- import { required } from 'vuelidate/lib/validators'
172
- import { Component, Mixins, Emit, Prop, Watch } from 'vue-property-decorator'
173
- import { Validations } from 'vuelidate-property-decorators'
174
- import { uniqueId } from 'lodash'
175
- import { ValidationMixin, CountriesProvincesMixin } from '@bcrs-shared-components/mixins'
176
-
177
- /**
178
- * The component for displaying and editing an address.
179
- * Vuelidate is used to implement the validation rules (eg, what 'required' means and whether it's satisfied).
180
- * Vuetify is used to display any validation errors/styling.
181
- * Optionally uses Canada Post AddressComplete (aka Postal Code Anywhere - PCA) for address lookup.
182
- */
183
- @Component({
184
- mixins: [ValidationMixin, CountriesProvincesMixin]
185
- })
186
- export default class BaseAddress extends Mixins(ValidationMixin, CountriesProvincesMixin) {
187
- /**
188
- * The validation object used by Vuelidate to compute address model validity.
189
- * @returns the Vuelidate validations object
190
- */
191
- @Validations()
192
- public validations (): any {
193
- return { addressLocal: { ...this.schemaLocal } }
194
- }
195
-
196
- /**
197
- * The address to be displayed/edited.
198
- * Default is "empty address" in case parent doesn't provide it (eg, for new address).
199
- */
200
- @Prop({
201
- default: () => ({
202
- streetAddress: '',
203
- streetAddressAdditional: '',
204
- addressCity: '',
205
- addressRegion: '',
206
- postalCode: '',
207
- addressCountry: '',
208
- deliveryInstructions: ''
209
- })
210
- })
211
- readonly address: object
212
-
213
- /** Whether the address should be shown in editing mode (true) or display mode (false). */
214
- @Prop({ default: false })
215
- readonly editing: boolean
216
-
217
- /** The address schema containing Vuelidate rules. */
218
- @Prop({ default: null })
219
- readonly schema: any
220
-
221
- @Prop({ default: false })
222
- readonly noPoBox: boolean
223
-
224
- @Prop({ default: '' })
225
- readonly deliveryInstructionsText: string
226
-
227
- @Prop({ default: false })
228
- readonly excludeBC: boolean
229
-
230
- resetRegion () {
231
- this.addressLocal['addressRegion'] = ''
232
- }
233
-
234
- /** A local (working) copy of the address, to contain the fields edited by the component (ie, the model). */
235
- addressLocal: object = {}
236
-
237
- /** A local (working) copy of the address schema. */
238
- schemaLocal: any = {}
239
-
240
- /** A unique id for this instance of this component. */
241
- uniqueId = uniqueId()
242
-
243
- /** A unique id for the Street Address input. */
244
- get streetAddressId (): string {
245
- return `street-address-${this.uniqueId}`
246
- }
247
-
248
- /** A unique id for the Address Country input. */
249
- addressCountryId (): string {
250
- return `address-country-${this.uniqueId}`
251
- }
252
-
253
- /** The Address Country, to simplify the template and so we can watch it below. */
254
- get addressCountry (): string {
255
- return this.addressLocal['addressCountry']
256
- }
257
-
258
- get isAddressCountryCanadaAndExcludeBc (): boolean {
259
- return this.addressLocal['addressCountry'] === 'CA' && this.excludeBC
260
- }
261
-
262
- /** The Street Address Additional label with 'optional' as needed. */
263
- get streetAddressAdditionalLabel (): string {
264
- return 'Additional Street Address' + (this.isSchemaRequired('streetAddressAdditional') ? '' : ' (Optional)')
265
- }
266
-
267
- /** The Street Address label with 'optional' as needed. */
268
- get streetAddressLabel (): string {
269
- return 'Street Address' + (this.isSchemaRequired('streetAddress') ? '' : ' (Optional)')
270
- }
271
-
272
- /** The Address City label with 'optional' as needed. */
273
- get addressCityLabel (): string {
274
- return 'City' + (this.isSchemaRequired('addressCity') ? '' : ' (Optional)')
275
- }
276
-
277
- /** The Address Region label with 'optional' as needed. */
278
- get addressRegionLabel (): string {
279
- let label: string
280
- let required = this.isSchemaRequired('addressRegion')
281
-
282
- // NB: make region required for Canada and USA
283
- if (this.addressLocal['addressCountry'] === 'CA') {
284
- label = 'Province'
285
- required = true
286
- } else if (this.addressLocal['addressCountry'] === 'US') {
287
- label = 'State'
288
- required = true
289
- } else {
290
- label = 'Province/State'
291
- }
292
-
293
- return label + (required ? '' : ' (Optional)')
294
- }
295
-
296
- /** The Postal Code label with 'optional' as needed. */
297
- get postalCodeLabel (): string {
298
- let label: string
299
- if (this.addressLocal['addressCountry'] === 'US') {
300
- label = 'Zip Code'
301
- } else {
302
- label = 'Postal Code'
303
- }
304
- return label + (this.isSchemaRequired('postalCode') ? '' : ' (Optional)')
305
- }
306
-
307
- /** The Address Country label with 'optional' as needed. */
308
- get addressCountryLabel (): string {
309
- return 'Country' + (this.isSchemaRequired('addressCountry') ? '' : ' (Optional)')
310
- }
311
-
312
- /** The Delivery Instructions label with 'optional' as needed. */
313
- get deliveryInstructionsLabel (): string {
314
- if (this.deliveryInstructionsText) {
315
- return this.deliveryInstructionsText + (this.isSchemaRequired('deliveryInstructions') ? '' : ' (Optional)')
316
- } else {
317
- return 'Delivery Instructions' + (this.isSchemaRequired('deliveryInstructions') ? '' : ' (Optional)')
318
- }
319
- }
320
-
321
- get streetAddressHint (): string {
322
- return this.noPoBox ? 'Address cannot be a PO Box' : ''
323
- }
324
-
325
- /** Whether the specified prop is required according to the schema. */
326
- isSchemaRequired (prop: string): boolean {
327
- return Boolean(this.schemaLocal && this.schemaLocal[prop] && this.schemaLocal[prop].required)
328
- }
329
-
330
- /** Array of validation rules used by input elements to prevent extra whitespace. */
331
- readonly spaceRules: Array<(v: string) => boolean | string> = [
332
- v => !/^\s/g.test(v) || 'Invalid spaces', // leading spaces
333
- v => !/\s$/g.test(v) || 'Invalid spaces', // trailing spaces
334
- v => !/\s\s/g.test(v) || 'Invalid word spacing' // multiple inline spaces
335
- ]
336
-
337
- /**
338
- * The Vuetify rules object. Used to display any validation errors/styling.
339
- * NB: As a getter, this is initialized between created() and mounted().
340
- * @returns the Vuetify validation rules object
341
- */
342
- get rules (): { [attr: string]: Array<() => boolean | string> } {
343
- return this.createVuetifyRulesObject('addressLocal') as { [attr: string]: Array<() => boolean | string> }
344
- }
345
- /** Emits an update message for the address prop, so that the caller can ".sync" with it. */
346
- @Emit('update:address')
347
- emitAddress (address: object): void { }
348
-
349
- /** Emits the validity of the address entered by the user. */
350
- @Emit('valid')
351
- emitValid (valid: boolean): void { }
352
-
353
- /**
354
- * Watches changes to the Schema object, so that if the parent changes the data, then
355
- * the working copy of it is updated.
356
- */
357
- @Watch('schema', { deep: true, immediate: true })
358
- onSchemaChanged (): void {
359
- this.schemaLocal = { ...this.schema }
360
- }
361
-
362
- /**
363
- * Watches changes to the Address object, so that if the parent changes the data, then
364
- * the working copy of it is updated.
365
- */
366
- @Watch('address', { deep: true, immediate: true })
367
- onAddressChanged (): void {
368
- this.addressLocal = { ...this.address }
369
- }
370
-
371
- /**
372
- * Watches changes to the Address Country and updates the schema accordingly.
373
- */
374
- @Watch('addressCountry')
375
- onAddressCountryChanged (): void {
376
- // skip this if component is called without a schema (eg, display mode)
377
- if (this.schema) {
378
- if (this.useCountryRegions(this.addressLocal['addressCountry'])) {
379
- // we are using a region list for the current country so make region a required field
380
- const addressRegion = { ...this.schema.addressRegion, required }
381
- // re-assign the local schema because Vue does not detect property addition
382
- this.schemaLocal = { ...this.schema, addressRegion }
383
- } else {
384
- // we are not using a region list for the current country so remove required property
385
- const { required, ...addressRegion } = this.schema.addressRegion
386
- // re-assign the local schema because Vue does not detect property deletion
387
- this.schemaLocal = { ...this.schema, addressRegion }
388
- }
389
- }
390
- }
391
-
392
- /**
393
- * Watches changes to the Address Local object, to catch any changes to the fields within the address.
394
- * Will notify the parent object with the new address and whether or not the address is valid.
395
- */
396
- @Watch('addressLocal', { deep: true, immediate: true })
397
- onAddressLocalChanged (): void {
398
- this.emitAddress(this.addressLocal)
399
- this.emitValid(!this.$v.$invalid)
400
- }
401
-
402
- /**
403
- * Determines whether to use a country's known regions (ie, provinces/states).
404
- * @param code the short code of the country
405
- * @returns whether to use v-select (true) or v-text-field (false) for input
406
- */
407
- useCountryRegions (code: string): boolean {
408
- return (code === 'CA' || code === 'US')
409
- }
410
-
411
- /** Enables AddressComplete for this instance of the address. */
412
- enableAddressComplete (): void {
413
- // If you want to use this component with the Canada Post AddressComplete service:
414
- // 1. The AddressComplete JavaScript script (and stylesheet) must be loaded.
415
- // 2. Your AddressComplete account key must be defined.
416
- const pca = window['pca']
417
- const key = window['addressCompleteKey']
418
- if (!pca || !key) {
419
- // eslint-disable-next-line no-console
420
- console.log('AddressComplete not initialized due to missing script and/or key')
421
- return
422
- }
423
-
424
- // Destroy the old object if it exists, and create a new one.
425
- if (window['currentAddressComplete']) {
426
- window['currentAddressComplete'].destroy()
427
- }
428
- window['currentAddressComplete'] = this.createAddressComplete(pca, key)
429
- }
430
-
431
- /**
432
- * Creates the AddressComplete object for this instance of the component.
433
- * @param pca the Postal Code Anywhere object provided by AddressComplete
434
- * @param key the key for the Canada Post account that is to be charged for lookups
435
- * @returns an object that is a pca.Address instance
436
- */
437
- createAddressComplete (pca, key: string): object {
438
- // Set up the two fields that AddressComplete will use for input.
439
- // Ref: https://www.canadapost.ca/pca/support/guides/advanced
440
- // Note: Use special field for country, which user can't click, and which AC will overwrite
441
- // but that we don't care about.
442
- const fields = [
443
- { element: this.streetAddressId, field: 'Line1', mode: pca.fieldMode.SEARCH },
444
- { element: this.addressCountryId, field: 'CountryName', mode: pca.fieldMode.COUNTRY }
445
- ]
446
- const options = { key }
447
-
448
- const addressComplete = new pca.Address(fields, options)
449
-
450
- // The documentation contains sample load/populate callback code that doesn't work, but this will. The side effect
451
- // is that it breaks the autofill functionality provided by the library, but we really don't want the library
452
- // altering the DOM because Vue is already doing so, and the two don't play well together.
453
- addressComplete.listen('populate', this.addressCompletePopulate)
454
-
455
- return addressComplete
456
- }
457
-
458
- /**
459
- * Callback to update the address data after the user chooses a suggested address.
460
- * @param address the data object returned by the AddressComplete Retrieve API
461
- */
462
- addressCompletePopulate (address: object): void {
463
- const newAddressLocal: object = {}
464
-
465
- newAddressLocal['streetAddress'] = address['Line1'] || 'N/A'
466
- // Combine extra address lines into Street Address Additional field.
467
- newAddressLocal['streetAddressAdditional'] = this.combineLines(
468
- this.combineLines(address['Line2'], address['Line3']),
469
- this.combineLines(address['Line4'], address['Line5'])
470
- )
471
- newAddressLocal['addressCity'] = address['City']
472
- if (this.useCountryRegions(address['CountryIso2'])) {
473
- // In this case, v-select will map known province code to province name
474
- // or v-select will be blank and user will have to select a known item.
475
- newAddressLocal['addressRegion'] = address['ProvinceCode']
476
- } else {
477
- // In this case, v-text-input will allow manual entry but province info is probably too long
478
- // so set region to null and add province name to the Street Address Additional field.
479
- // If length is excessive, user will have to fix it.
480
- newAddressLocal['addressRegion'] = null
481
- newAddressLocal['streetAddressAdditional'] = this.combineLines(
482
- newAddressLocal['streetAddressAdditional'], address['ProvinceName']
483
- )
484
- }
485
- newAddressLocal['postalCode'] = address['PostalCode']
486
- newAddressLocal['addressCountry'] = address['CountryIso2']
487
-
488
- // re-assign the local address to force Vuetify update
489
- this.addressLocal = newAddressLocal
490
-
491
- // Validate the form, in case any fields are missing or incorrect.
492
- Vue.nextTick(() => { (this.$refs.addressForm as any).validate() })
493
- }
494
-
495
- combineLines (line1: string, line2: string) {
496
- if (!line1) return line2
497
- if (!line2) return line1
498
- return line1 + '\n' + line2
499
- }
500
- }
501
- </script>
502
-
503
- <style lang="scss" scoped>
504
- @import "@/assets/styles/theme.scss";
505
-
506
- // Address Block Layout
507
- .address-block {
508
- display: flex;
509
- }
510
-
511
- .address-block__info {
512
- flex: 1 1 auto;
513
- }
514
-
515
- .address-block__info-row {
516
- color: $gray7;
517
- }
518
-
519
- // Form Row Elements
520
- .form__row.three-column {
521
- align-items: stretch;
522
- display: flex;
523
- flex-flow: row nowrap;
524
- margin-left: -0.5rem;
525
- margin-right: -0.5rem;
526
-
527
- .item {
528
- flex: 1 1 auto;
529
- flex-basis: 0;
530
- margin-left: 0.5rem;
531
- margin-right: 0.5rem;
532
- }
533
- }
534
-
535
- // text field labels
536
- ::v-deep .v-label {
537
- color: $gray7;
538
- font-size: $px-16;
539
- font-weight: normal;
540
- }
541
-
542
- // text field inputs
543
- ::v-deep {
544
- .v-input input {
545
- color: $gray9;
546
- }
547
- }
548
-
549
- .pre-line {
550
- white-space: pre-line;
551
- }
552
-
553
- // make 'readonly' inputs looks disabled
554
- // (can't use 'disabled' because we want normal error styling)
555
- .v-select.v-input--is-readonly,
556
- .v-text-field.v-input--is-readonly {
557
- pointer-events: none;
558
-
559
- ::v-deep .v-label {
560
- // set label colour to same as disabled
561
- color: rgba(0,0,0,.38);
562
- }
563
-
564
- ::v-deep .v-select__selection {
565
- // set selection colour to same as disabled
566
- color: rgba(0,0,0,.38);
567
- }
568
-
569
- ::v-deep .v-icon {
570
- // set error icon colour to same as disabled
571
- color: rgba(0,0,0,.38) !important;
572
- opacity: 0.6;
573
- }
574
- }
575
- </style>
1
+ //
2
+ // Copyright © 2020 Province of British Columbia
3
+ //
4
+ // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
5
+ // the License. You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
10
+ // an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
11
+ // specific language governing permissions and limitations under the License.
12
+ //
13
+
14
+ <template>
15
+ <div class="base-address">
16
+ <!-- Display fields -->
17
+ <v-expand-transition>
18
+ <div
19
+ v-if="!editing"
20
+ class="address-block"
21
+ >
22
+ <div class="address-block__info pre-line">
23
+ <div class="address-block__info-row street-address">
24
+ {{ addressLocal.streetAddress }}
25
+ </div>
26
+
27
+ <div class="address-block__info-row street-address-additional">
28
+ {{ addressLocal.streetAddressAdditional }}
29
+ </div>
30
+
31
+ <div class="address-block__info-row">
32
+ <span class="address-city">{{ addressLocal.addressCity }}</span>
33
+
34
+ <template v-if="addressLocal.addressRegion">
35
+ <span class="address-region">&nbsp;{{ addressLocal.addressRegion }}</span>
36
+ </template>
37
+
38
+ <template v-if="addressLocal.postalCode">
39
+ <span class="postal-code">&nbsp;{{ addressLocal.postalCode }}</span>
40
+ </template>
41
+ </div>
42
+
43
+ <div class="address-block__info-row address-country">
44
+ {{ getCountryName(addressCountry) }}
45
+ </div>
46
+
47
+ <template v-if="addressLocal.deliveryInstructions">
48
+ <div class="address-block__info-row delivery-instructions mt-5 font-italic">
49
+ {{ addressLocal.deliveryInstructions }}
50
+ </div>
51
+ </template>
52
+ </div>
53
+ </div>
54
+ </v-expand-transition>
55
+
56
+ <!-- Edit fields -->
57
+ <v-expand-transition>
58
+ <v-form
59
+ v-if="editing"
60
+ ref="addressForm"
61
+ name="address-form"
62
+ lazy-validation
63
+ >
64
+ <div class="form__row">
65
+ <v-select
66
+ v-model="addressLocal.addressCountry"
67
+ filled
68
+ class="address-country"
69
+ :label="addressCountryLabel"
70
+ menu-props="auto"
71
+ item-text="name"
72
+ item-value="code"
73
+ :items="getCountries()"
74
+ :rules="[...rules.addressCountry, ...spaceRules]"
75
+ @change="resetRegion()"
76
+ />
77
+ <!-- special field to select AddressComplete country, separate from our model field -->
78
+ <input
79
+ :id="addressCountryId"
80
+ type="hidden"
81
+ :value="addressCountry"
82
+ >
83
+ </div>
84
+ <div class="form__row">
85
+ <!-- NB1: AddressComplete needs to be enabled each time user clicks in this search field.
86
+ NB2: Only process first keypress -- assumes if user moves between instances of this
87
+ component then they are using the mouse (and thus, clicking). -->
88
+ <v-text-field
89
+ :id="streetAddressId"
90
+ v-model="addressLocal.streetAddress"
91
+ autocomplete="chrome-off"
92
+ :name="Math.random()"
93
+ filled
94
+ class="street-address"
95
+ :hint="streetAddressHint"
96
+ persistent-hint
97
+ :label="streetAddressLabel"
98
+ :rules="[...rules.streetAddress, ...spaceRules]"
99
+ @keypress.once="enableAddressComplete()"
100
+ @click="enableAddressComplete()"
101
+ />
102
+ </div>
103
+ <div class="form__row">
104
+ <v-textarea
105
+ v-model="addressLocal.streetAddressAdditional"
106
+ autocomplete="chrome-off"
107
+ :name="Math.random()"
108
+ auto-grow
109
+ filled
110
+ class="street-address-additional"
111
+ :label="streetAddressAdditionalLabel"
112
+ rows="1"
113
+ :rules="[...rules.streetAddressAdditional, ...spaceRules]"
114
+ />
115
+ </div>
116
+ <div class="form__row three-column">
117
+ <v-text-field
118
+ v-model="addressLocal.addressCity"
119
+ filled
120
+ class="item address-city"
121
+ :label="addressCityLabel"
122
+ :rules="[...rules.addressCity, ...spaceRules]"
123
+ />
124
+ <v-select
125
+ v-if="useCountryRegions(addressCountry)"
126
+ v-model="addressLocal.addressRegion"
127
+ filled
128
+ class="item address-region"
129
+ :menu-props="{maxHeight:'40rem'}"
130
+ :label="addressRegionLabel"
131
+ item-text="name"
132
+ item-value="short"
133
+ :items="isAddressCountryCanadaAndExcludeBc ? getCanadaRegionsExcludeBC() :
134
+ getCountryRegions(addressCountry)"
135
+ :rules="[...rules.addressRegion, ...spaceRules]"
136
+ />
137
+ <v-text-field
138
+ v-else
139
+ v-model="addressLocal.addressRegion"
140
+ filled
141
+ class="item address-region"
142
+ :label="addressRegionLabel"
143
+ :rules="[...rules.addressRegion, ...spaceRules]"
144
+ />
145
+ <v-text-field
146
+ v-model="addressLocal.postalCode"
147
+ filled
148
+ class="item postal-code"
149
+ :label="postalCodeLabel"
150
+ :rules="[...rules.postalCode, ...spaceRules]"
151
+ />
152
+ </div>
153
+ <div class="form__row">
154
+ <v-textarea
155
+ v-model="addressLocal.deliveryInstructions"
156
+ auto-grow
157
+ filled
158
+ class="delivery-instructions"
159
+ :label="deliveryInstructionsLabel"
160
+ rows="2"
161
+ :rules="[...rules.deliveryInstructions, ...spaceRules]"
162
+ />
163
+ </div>
164
+ </v-form>
165
+ </v-expand-transition>
166
+ </div>
167
+ </template>
168
+
169
+ <script lang="ts">
170
+ import Vue from 'vue'
171
+ import { required } from 'vuelidate/lib/validators'
172
+ import { Component, Mixins, Emit, Prop, Watch } from 'vue-property-decorator'
173
+ import { Validations } from 'vuelidate-property-decorators'
174
+ import { uniqueId } from 'lodash'
175
+ import { ValidationMixin, CountriesProvincesMixin } from '@bcrs-shared-components/mixins'
176
+
177
+ /**
178
+ * The component for displaying and editing an address.
179
+ * Vuelidate is used to implement the validation rules (eg, what 'required' means and whether it's satisfied).
180
+ * Vuetify is used to display any validation errors/styling.
181
+ * Optionally uses Canada Post AddressComplete (aka Postal Code Anywhere - PCA) for address lookup.
182
+ */
183
+ @Component({
184
+ mixins: [ValidationMixin, CountriesProvincesMixin]
185
+ })
186
+ export default class BaseAddress extends Mixins(ValidationMixin, CountriesProvincesMixin) {
187
+ /**
188
+ * The validation object used by Vuelidate to compute address model validity.
189
+ * @returns the Vuelidate validations object
190
+ */
191
+ @Validations()
192
+ public validations (): any {
193
+ return { addressLocal: { ...this.schemaLocal } }
194
+ }
195
+
196
+ /**
197
+ * The address to be displayed/edited.
198
+ * Default is "empty address" in case parent doesn't provide it (eg, for new address).
199
+ */
200
+ @Prop({
201
+ default: () => ({
202
+ streetAddress: '',
203
+ streetAddressAdditional: '',
204
+ addressCity: '',
205
+ addressRegion: '',
206
+ postalCode: '',
207
+ addressCountry: 'CA',
208
+ deliveryInstructions: ''
209
+ })
210
+ })
211
+ readonly address: object
212
+
213
+ /** Whether the address should be shown in editing mode (true) or display mode (false). */
214
+ @Prop({ default: false })
215
+ readonly editing: boolean
216
+
217
+ /** The address schema containing Vuelidate rules. */
218
+ @Prop({ default: null })
219
+ readonly schema: any
220
+
221
+ @Prop({ default: false })
222
+ readonly noPoBox: boolean
223
+
224
+ @Prop({ default: '' })
225
+ readonly deliveryInstructionsText: string
226
+
227
+ @Prop({ default: false })
228
+ readonly excludeBC: boolean
229
+
230
+ resetRegion () {
231
+ this.addressLocal['addressRegion'] = ''
232
+ }
233
+
234
+ /** A local (working) copy of the address, to contain the fields edited by the component (ie, the model). */
235
+ addressLocal: object = {}
236
+
237
+ /** A local (working) copy of the address schema. */
238
+ schemaLocal: any = {}
239
+
240
+ /** A unique id for this instance of this component. */
241
+ uniqueId = uniqueId()
242
+
243
+ /** A unique id for the Street Address input. */
244
+ get streetAddressId (): string {
245
+ return `street-address-${this.uniqueId}`
246
+ }
247
+
248
+ /** A unique id for the Address Country input. */
249
+ get addressCountryId (): string {
250
+ return `address-country-${this.uniqueId}`
251
+ }
252
+
253
+ /** The Address Country, to simplify the template and so we can watch it below. */
254
+ get addressCountry (): string {
255
+ return this.addressLocal['addressCountry']
256
+ }
257
+
258
+ get isAddressCountryCanadaAndExcludeBc (): boolean {
259
+ return this.addressLocal['addressCountry'] === 'CA' && this.excludeBC
260
+ }
261
+
262
+ /** The Street Address Additional label with 'optional' as needed. */
263
+ get streetAddressAdditionalLabel (): string {
264
+ return 'Additional Street Address' + (this.isSchemaRequired('streetAddressAdditional') ? '' : ' (Optional)')
265
+ }
266
+
267
+ /** The Street Address label with 'optional' as needed. */
268
+ get streetAddressLabel (): string {
269
+ return 'Street Address' + (this.isSchemaRequired('streetAddress') ? '' : ' (Optional)')
270
+ }
271
+
272
+ /** The Address City label with 'optional' as needed. */
273
+ get addressCityLabel (): string {
274
+ return 'City' + (this.isSchemaRequired('addressCity') ? '' : ' (Optional)')
275
+ }
276
+
277
+ /** The Address Region label with 'optional' as needed. */
278
+ get addressRegionLabel (): string {
279
+ let label: string
280
+ let required = this.isSchemaRequired('addressRegion')
281
+
282
+ // NB: make region required for Canada and USA
283
+ if (this.addressLocal['addressCountry'] === 'CA') {
284
+ label = 'Province'
285
+ required = true
286
+ } else if (this.addressLocal['addressCountry'] === 'US') {
287
+ label = 'State'
288
+ required = true
289
+ } else {
290
+ label = 'Province/State'
291
+ }
292
+
293
+ return label + (required ? '' : ' (Optional)')
294
+ }
295
+
296
+ /** The Postal Code label with 'optional' as needed. */
297
+ get postalCodeLabel (): string {
298
+ let label: string
299
+ if (this.addressLocal['addressCountry'] === 'US') {
300
+ label = 'Zip Code'
301
+ } else {
302
+ label = 'Postal Code'
303
+ }
304
+ return label + (this.isSchemaRequired('postalCode') ? '' : ' (Optional)')
305
+ }
306
+
307
+ /** The Address Country label with 'optional' as needed. */
308
+ get addressCountryLabel (): string {
309
+ return 'Country' + (this.isSchemaRequired('addressCountry') ? '' : ' (Optional)')
310
+ }
311
+
312
+ /** The Delivery Instructions label with 'optional' as needed. */
313
+ get deliveryInstructionsLabel (): string {
314
+ if (this.deliveryInstructionsText) {
315
+ return this.deliveryInstructionsText + (this.isSchemaRequired('deliveryInstructions') ? '' : ' (Optional)')
316
+ } else {
317
+ return 'Delivery Instructions' + (this.isSchemaRequired('deliveryInstructions') ? '' : ' (Optional)')
318
+ }
319
+ }
320
+
321
+ get streetAddressHint (): string {
322
+ return this.noPoBox ? 'Address cannot be a PO Box' : ''
323
+ }
324
+
325
+ /** Whether the specified prop is required according to the schema. */
326
+ isSchemaRequired (prop: string): boolean {
327
+ return Boolean(this.schemaLocal && this.schemaLocal[prop] && this.schemaLocal[prop].required)
328
+ }
329
+
330
+ /** Array of validation rules used by input elements to prevent extra whitespace. */
331
+ readonly spaceRules: Array<(v: string) => boolean | string> = [
332
+ v => !/^\s/g.test(v) || 'Invalid spaces', // leading spaces
333
+ v => !/\s$/g.test(v) || 'Invalid spaces', // trailing spaces
334
+ v => !/\s\s/g.test(v) || 'Invalid word spacing' // multiple inline spaces
335
+ ]
336
+
337
+ /**
338
+ * The Vuetify rules object. Used to display any validation errors/styling.
339
+ * NB: As a getter, this is initialized between created() and mounted().
340
+ * @returns the Vuetify validation rules object
341
+ */
342
+ get rules (): { [attr: string]: Array<() => boolean | string> } {
343
+ return this.createVuetifyRulesObject('addressLocal') as { [attr: string]: Array<() => boolean | string> }
344
+ }
345
+ /** Emits an update message for the address prop, so that the caller can ".sync" with it. */
346
+ @Emit('update:address')
347
+ emitAddress (address: object): void { }
348
+
349
+ /** Emits the validity of the address entered by the user. */
350
+ @Emit('valid')
351
+ emitValid (valid: boolean): void { }
352
+
353
+ /**
354
+ * Watches changes to the Schema object, so that if the parent changes the data, then
355
+ * the working copy of it is updated.
356
+ */
357
+ @Watch('schema', { deep: true, immediate: true })
358
+ onSchemaChanged (): void {
359
+ this.schemaLocal = { ...this.schema }
360
+ }
361
+
362
+ /**
363
+ * Watches changes to the Address object, so that if the parent changes the data, then
364
+ * the working copy of it is updated.
365
+ */
366
+ @Watch('address', { deep: true, immediate: true })
367
+ onAddressChanged (): void {
368
+ this.addressLocal = { ...this.address }
369
+ }
370
+
371
+ /**
372
+ * Watches changes to the Address Country and updates the schema accordingly.
373
+ */
374
+ @Watch('addressCountry')
375
+ onAddressCountryChanged (): void {
376
+ // skip this if component is called without a schema (eg, display mode)
377
+ if (this.schema) {
378
+ if (this.useCountryRegions(this.addressLocal['addressCountry'])) {
379
+ // we are using a region list for the current country so make region a required field
380
+ const addressRegion = { ...this.schema.addressRegion, required }
381
+ // re-assign the local schema because Vue does not detect property addition
382
+ this.schemaLocal = { ...this.schema, addressRegion }
383
+ } else {
384
+ // we are not using a region list for the current country so remove required property
385
+ const { required, ...addressRegion } = this.schema.addressRegion
386
+ // re-assign the local schema because Vue does not detect property deletion
387
+ this.schemaLocal = { ...this.schema, addressRegion }
388
+ }
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Watches changes to the Address Local object, to catch any changes to the fields within the address.
394
+ * Will notify the parent object with the new address and whether or not the address is valid.
395
+ */
396
+ @Watch('addressLocal', { deep: true, immediate: true })
397
+ onAddressLocalChanged (): void {
398
+ this.emitAddress(this.addressLocal)
399
+ this.emitValid(!this.$v.$invalid)
400
+ }
401
+
402
+ /**
403
+ * Determines whether to use a country's known regions (ie, provinces/states).
404
+ * @param code the short code of the country
405
+ * @returns whether to use v-select (true) or v-text-field (false) for input
406
+ */
407
+ useCountryRegions (code: string): boolean {
408
+ return (code === 'CA' || code === 'US')
409
+ }
410
+
411
+ /** Enables AddressComplete for this instance of the address. */
412
+ enableAddressComplete (): void {
413
+ // If you want to use this component with the Canada Post AddressComplete service:
414
+ // 1. The AddressComplete JavaScript script (and stylesheet) must be loaded.
415
+ // 2. Your AddressComplete account key must be defined.
416
+ const pca = window['pca']
417
+ const key = window['addressCompleteKey']
418
+ if (!pca || !key) {
419
+ // eslint-disable-next-line no-console
420
+ console.log('AddressComplete not initialized due to missing script and/or key')
421
+ return
422
+ }
423
+
424
+ // Destroy the old object if it exists, and create a new one.
425
+ if (window['currentAddressComplete']) {
426
+ window['currentAddressComplete'].destroy()
427
+ }
428
+ window['currentAddressComplete'] = this.createAddressComplete(pca, key)
429
+ }
430
+
431
+ /**
432
+ * Creates the AddressComplete object for this instance of the component.
433
+ * @param pca the Postal Code Anywhere object provided by AddressComplete
434
+ * @param key the key for the Canada Post account that is to be charged for lookups
435
+ * @returns an object that is a pca.Address instance
436
+ */
437
+ createAddressComplete (pca, key: string): object {
438
+ // Set up the two fields that AddressComplete will use for input.
439
+ // Ref: https://www.canadapost.ca/pca/support/guides/advanced
440
+ // Note: Use special field for country, which user can't click, and which AC will overwrite
441
+ // but that we don't care about.
442
+ const fields = [
443
+ { element: this.streetAddressId, field: 'Line1', mode: pca.fieldMode.SEARCH },
444
+ { element: this.addressCountryId, field: 'CountryName', mode: pca.fieldMode.COUNTRY }
445
+ ]
446
+ const options = {
447
+ key,
448
+ bar: {
449
+ visible: false
450
+ }
451
+ }
452
+
453
+ const addressComplete = new pca.Address(fields, options)
454
+
455
+ // The documentation contains sample load/populate callback code that doesn't work, but this will. The side effect
456
+ // is that it breaks the autofill functionality provided by the library, but we really don't want the library
457
+ // altering the DOM because Vue is already doing so, and the two don't play well together.
458
+ addressComplete.listen('populate', this.addressCompletePopulate)
459
+
460
+ return addressComplete
461
+ }
462
+
463
+ /**
464
+ * Callback to update the address data after the user chooses a suggested address.
465
+ * @param address the data object returned by the AddressComplete Retrieve API
466
+ */
467
+ addressCompletePopulate (address: object): void {
468
+ const newAddressLocal: object = {}
469
+
470
+ newAddressLocal['streetAddress'] = address['Line1'] || 'N/A'
471
+ // Combine extra address lines into Street Address Additional field.
472
+ newAddressLocal['streetAddressAdditional'] = this.combineLines(
473
+ this.combineLines(address['Line2'], address['Line3']),
474
+ this.combineLines(address['Line4'], address['Line5'])
475
+ )
476
+ newAddressLocal['addressCity'] = address['City']
477
+ if (this.useCountryRegions(address['CountryIso2'])) {
478
+ // In this case, v-select will map known province code to province name
479
+ // or v-select will be blank and user will have to select a known item.
480
+ newAddressLocal['addressRegion'] = address['ProvinceCode']
481
+ } else {
482
+ // In this case, v-text-input will allow manual entry but province info is probably too long
483
+ // so set region to null and add province name to the Street Address Additional field.
484
+ // If length is excessive, user will have to fix it.
485
+ newAddressLocal['addressRegion'] = null
486
+ newAddressLocal['streetAddressAdditional'] = this.combineLines(
487
+ newAddressLocal['streetAddressAdditional'], address['ProvinceName']
488
+ )
489
+ }
490
+ newAddressLocal['postalCode'] = address['PostalCode']
491
+ newAddressLocal['addressCountry'] = address['CountryIso2']
492
+
493
+ // re-assign the local address to force Vuetify update
494
+ this.addressLocal = newAddressLocal
495
+
496
+ // Validate the form, in case any fields are missing or incorrect.
497
+ Vue.nextTick(() => { (this.$refs.addressForm as any).validate() })
498
+ }
499
+
500
+ combineLines (line1: string, line2: string) {
501
+ if (!line1) return line2
502
+ if (!line2) return line1
503
+ return line1 + '\n' + line2
504
+ }
505
+ }
506
+ </script>
507
+
508
+ <style lang="scss" scoped>
509
+ @import "@/assets/styles/theme.scss";
510
+
511
+ // Address Block Layout
512
+ .address-block {
513
+ display: flex;
514
+ }
515
+
516
+ .address-block__info {
517
+ flex: 1 1 auto;
518
+ }
519
+
520
+ .address-block__info-row {
521
+ color: $gray7;
522
+ }
523
+
524
+ // Form Row Elements
525
+ .form__row.three-column {
526
+ align-items: stretch;
527
+ display: flex;
528
+ flex-flow: row nowrap;
529
+ margin-left: -0.5rem;
530
+ margin-right: -0.5rem;
531
+
532
+ .item {
533
+ flex: 1 1 auto;
534
+ flex-basis: 0;
535
+ margin-left: 0.5rem;
536
+ margin-right: 0.5rem;
537
+ }
538
+ }
539
+
540
+ // text field labels
541
+ ::v-deep .v-label {
542
+ color: $gray7;
543
+ font-size: $px-16;
544
+ font-weight: normal;
545
+ }
546
+
547
+ // text field inputs
548
+ ::v-deep {
549
+ .v-input input {
550
+ color: $gray9;
551
+ }
552
+ }
553
+
554
+ .pre-line {
555
+ white-space: pre-line;
556
+ }
557
+
558
+ // make 'readonly' inputs looks disabled
559
+ // (can't use 'disabled' because we want normal error styling)
560
+ .v-select.v-input--is-readonly,
561
+ .v-text-field.v-input--is-readonly {
562
+ pointer-events: none;
563
+
564
+ ::v-deep .v-label {
565
+ // set label colour to same as disabled
566
+ color: rgba(0,0,0,.38);
567
+ }
568
+
569
+ ::v-deep .v-select__selection {
570
+ // set selection colour to same as disabled
571
+ color: rgba(0,0,0,.38);
572
+ }
573
+
574
+ ::v-deep .v-icon {
575
+ // set error icon colour to same as disabled
576
+ color: rgba(0,0,0,.38) !important;
577
+ opacity: 0.6;
578
+ }
579
+ }
580
+ </style>