diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 60149976..e4505c18 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,8 +17,8 @@ jobs: node-version: '16' cache: 'yarn' - run: yarn install --immutable --immutable-cache --check-cache - - run: yarn prod - run: yarn test-coverage + - run: yarn prod - name: Coveralls uses: coverallsapp/github-action@master with: diff --git a/index.d.ts b/index.d.ts index 714ab6b9..5e3644b7 100644 --- a/index.d.ts +++ b/index.d.ts @@ -24,7 +24,7 @@ interface TSMLReactConfig { }; distance_unit: 'mi' | 'km'; /** Email addresses for update meeting info button */ - feedback_emails: []; + feedback_emails: string[]; filters: Array<'region' | 'distance' | 'weekday' | 'time' | 'type'>; flags: Array<'M' | 'W'> | undefined | null; in_person_types: MeetingType[]; diff --git a/jest.setup.js b/jest.setup.js index b5abf957..f7b114a9 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,5 +1,8 @@ import '@testing-library/jest-dom/extend-expect'; import React from 'react'; +import * as momentTZ from 'moment-timezone'; + +momentTZ.tz.setDefault('America/New_York'); global.React = React; diff --git a/src/helpers/format.ts b/src/helpers/format.ts deleted file mode 100644 index f78fdec3..00000000 --- a/src/helpers/format.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { Meeting } from '../types/Meeting'; -import { getQueryString } from './query-string'; -import { settings, strings } from './settings'; - -//get address from formatted_address -export function formatAddress(formatted_address = '') { - const address = formatted_address.split(', '); - return address.length > 3 ? address[0] : null; -} - -//ensure array-ness for formatFeedbackEmail() -function formatArray(unknown: unknown) { - if (Array.isArray(unknown)) return unknown; - const type = typeof unknown; - if (type === 'string') return [unknown]; - //@ts-expect-error TODO - if (type === 'object') return Object.values(unknown); - return []; -} - -//get name of provider from url -export function formatConferenceProvider(url: string) { - const urlParts = url.split('/'); - if (urlParts.length < 2) return null; - const provider = Object.keys(settings.conference_providers).filter(domain => - urlParts[2].endsWith(domain) - ); - return provider.length ? settings.conference_providers[provider[0]] : null; -} - -//inspired by the functionality of jedwatson/classnames -export function formatClasses() { - return Object.values(arguments) - .map(arg => - typeof arg === 'string' - ? arg - : Array.isArray(arg) - ? arg.join(' ') - : typeof arg === 'object' - ? Object.keys(arg) - .filter(key => !!arg[key]) - .join(' ') - : null - ) - .filter(e => e) - .join(' '); -} - -//send back a string url to get directions with the appropriate provider -export function formatDirectionsUrl({ - formatted_address, - latitude, - longitude, -}: { - formatted_address: string; - latitude: number; - longitude: number; -}) { - //create a link for directions - const iOS = navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform); - const baseURL = iOS ? 'maps://' : 'https://www.google.com/maps'; - const params: { saddr: string; daddr?: string; q?: string } = { - saddr: 'Current Location', - }; - - if (latitude && longitude) { - params['daddr'] = [latitude, longitude].join(); - params['q'] = formatted_address; - } else { - params['daddr'] = formatted_address; - } - - return `${baseURL}?${new URLSearchParams(params)}`; -} - -//send back a mailto link to a feedback email -export function formatFeedbackEmail( - feedback_emails: TSMLReactConfig['feedback_emails'], - meeting: Meeting -) { - //remove extra query params from meeting URL - const input = getQueryString(); - const meetingUrl = formatUrl({ meeting: input.meeting }); - - //build message - const lines = [ - ``, - '', - '', - '-----', - strings.email_public_url.replace('%url%', meetingUrl), - ]; - if (meeting.edit_url) { - lines.push(strings.email_edit_url.replace('%url%', meeting.edit_url)); - } - - //build mailto link - return `mailto:${formatArray(feedback_emails).join()}?${new URLSearchParams({ - subject: strings.email_subject.replace('%name%', meeting.name), - body: lines.join('\n'), - }) - .toString() - .replaceAll('+', ' ')}`; -} - -//format ICS file for add to calendar -export function formatIcs(meeting: Meeting) { - const fmt = 'YYYYMMDDTHHmmss'; - - const url = [ - 'BEGIN:VCALENDAR', - 'VERSION:2.0', - 'BEGIN:VEVENT', - `SUMMARY:${meeting.name}`, - `DTSTART:${meeting.start.clone().tz('UTC').format(fmt)}Z`, - `DTSTART;TZID=/${meeting.timezone}:${meeting.start.format(fmt)}`, - 'END:VEVENT', - 'END:VCALENDAR', - ]; - - if (meeting.end) { - url.splice( - -2, - 0, - `DTEND:${meeting.end.clone().tz('UTC').format(fmt)}Z`, - `DTEND;TZID=/${meeting.timezone}:${meeting.end.format(fmt)}` - ); - } else { - url.splice( - -2, - 0, - `DTEND:${meeting.start.clone().add(1, 'hour').tz('UTC').format(fmt)}Z`, - `DTEND;TZID=/${meeting.timezone}:${meeting.start - .clone() - .add(1, 'hour') - .format(fmt)}` - ); - } - - //start building notes - const notes = [ - meeting.conference_url_notes, - meeting.conference_phone_notes, - meeting.notes, - meeting.location_notes, - ]; - - if (meeting.isInPerson) { - //address for in-person meetings - url.splice( - -2, - 0, - `LOCATION:${meeting.formatted_address.replaceAll(',', ';')}` - ); - if (meeting.location) { - notes.push(meeting.location); - } - if (meeting.latitude && meeting.longitude) { - url.splice(-2, 0, `GEO:${meeting.latitude};${meeting.longitude}`); - } - } else if (meeting.location) { - //location name - url.splice(-2, 0, `LOCATION:${meeting.location}`); - } - - //add notes if present - const notesString = notes.filter(e => e).join('\\n'); - if (notesString) { - url.splice(-2, 0, `DESCRIPTION:${notesString}`); - } - - //add URL if present - if (meeting.conference_provider) { - url.splice(-2, 0, `URL:${meeting.conference_url.replaceAll('&', '&')}`); - } - - const urlString = url.join('\n'); - - if (/msie\s|trident\/|edge\//i.test(window.navigator.userAgent)) { - //open/save link in IE and Edge - const blob = new Blob([urlString], { type: 'text/calendar;charset=utf-8' }); - window.navigator.msSaveBlob(blob, 'download.ics'); - } else { - //open/save link in modern browsers - const uri = `data:text/calendar;charset=utf8,${urlString}`; - window.location = encodeURI(uri) as unknown as Location; - } -} - -//turn Mountain View into mountain-view -export function formatSlug(str: string) { - str = str.trim().toLowerCase(); - - // remove accents, swap ñ for n, etc - const from = 'åàáãäâèéëêìíïîòóöôùúüûñç·/_,:;'; - const to = 'aaaaaaeeeeiiiioooouuuunc------'; - - for (let i = 0, l = from.length; i < l; i++) { - str = str.replace(new RegExp(from.charAt(i), 'g'), to.charAt(i)); - } - - return str - .replace(/[^a-z0-9 -]/g, '') // remove invalid chars - .replace(/\s+/g, '-') // collapse whitespace and replace by - - .replace(/-+/g, '-') // collapse dashes - .replace(/^-+/, '') // trim - from start of text - .replace(/-+$/, ''); // trim - from end of text -} - -//format an internal link with correct query params -export function formatUrl(input: Partial) { - const query = {}; - - //distance, region, time, type, and weekday - settings.filters - .filter(filter => typeof input[filter] !== 'undefined') - .filter(filter => input[filter]?.length) - .forEach(filter => { - // @ts-expect-error TODO - query[filter] = input[filter].join('/'); - }); - - //meeting, mode, search, view - settings.params - .filter(param => typeof input[param] !== 'undefined') - .filter(param => input[param] !== settings.defaults[param]) - .forEach(param => { - // @ts-expect-error TODO - query[param] = input[param]; - }); - - //create a query string with only values in use - const queryString = new URLSearchParams(query) - .toString() - .replace(/%2F/g, '/') - .replace(/%20/g, '+') - .replace(/%2C/g, ','); - - const [path] = window.location.href.split('?'); - - return `${path}${!!queryString.length ? `?${queryString}` : ''}`; -} diff --git a/src/helpers/format/format-address.spec.ts b/src/helpers/format/format-address.spec.ts new file mode 100644 index 00000000..86d9cef7 --- /dev/null +++ b/src/helpers/format/format-address.spec.ts @@ -0,0 +1,11 @@ +import { formatAddress } from './format-address'; + +describe('formatAddress', () => { + it('returns first part of address if length > 3', () => { + expect(formatAddress('foo, bar, baz, qux')).toStrictEqual('foo'); + }); + + it.each([undefined, 'foo, bar, baz'])('yields null with %s', input => { + expect(formatAddress(input)).toStrictEqual(null); + }); +}); diff --git a/src/helpers/format/format-address.ts b/src/helpers/format/format-address.ts new file mode 100644 index 00000000..479e474d --- /dev/null +++ b/src/helpers/format/format-address.ts @@ -0,0 +1,5 @@ +//get address from formatted_address +export function formatAddress(formatted_address = '') { + const address = formatted_address.split(', '); + return address.length > 3 ? address[0] : null; +} diff --git a/src/helpers/format/format-array.spec.ts b/src/helpers/format/format-array.spec.ts new file mode 100644 index 00000000..a72e6eb3 --- /dev/null +++ b/src/helpers/format/format-array.spec.ts @@ -0,0 +1,13 @@ +import { formatArray } from './format-array'; + +describe('formatArray', () => { + it.each` + input | expected + ${[]} | ${[]} + ${'foo'} | ${['foo']} + ${{ foo: 'bar' }} | ${['bar']} + ${undefined} | ${[]} + `('yields $expected with $input', ({ input, expected }) => { + expect(formatArray(input)).toStrictEqual(expected); + }); +}); diff --git a/src/helpers/format/format-array.ts b/src/helpers/format/format-array.ts new file mode 100644 index 00000000..614e58de --- /dev/null +++ b/src/helpers/format/format-array.ts @@ -0,0 +1,9 @@ +//ensure array-ness for formatFeedbackEmail() +export function formatArray(unknown: unknown) { + if (Array.isArray(unknown)) return unknown; + const type = typeof unknown; + if (type === 'string') return [unknown]; + //@ts-expect-error TODO + if (type === 'object') return Object.values(unknown); + return []; +} diff --git a/src/helpers/format/format-classes.spec.ts b/src/helpers/format/format-classes.spec.ts new file mode 100644 index 00000000..22608042 --- /dev/null +++ b/src/helpers/format/format-classes.spec.ts @@ -0,0 +1,14 @@ +import { formatClasses } from './format-classes'; + +describe('formatClasses', () => { + it.each` + input | expected + ${'foo'} | ${'foo'} + ${{ foo: true }} | ${'foo'} + ${{ foo: false }} | ${''} + ${undefined} | ${''} + ${['foo']} | ${'foo'} + `('yields $expected with $input', ({ input, expected }) => { + expect(formatClasses(input)).toStrictEqual(expected); + }); +}); diff --git a/src/helpers/format/format-classes.ts b/src/helpers/format/format-classes.ts new file mode 100644 index 00000000..68179de9 --- /dev/null +++ b/src/helpers/format/format-classes.ts @@ -0,0 +1,19 @@ +//inspired by the functionality of jedwatson/classnames +export function formatClasses( + _args: Array> +) { + return Object.values(arguments) + .map(arg => + typeof arg === 'string' + ? arg + : Array.isArray(arg) + ? arg.join(' ') + : typeof arg === 'object' + ? Object.keys(arg) + .filter(key => !!arg[key]) + .join(' ') + : null + ) + .filter(e => e) + .join(' '); +} diff --git a/src/helpers/format/format-conference-provider.spec.ts b/src/helpers/format/format-conference-provider.spec.ts new file mode 100644 index 00000000..b4a800ee --- /dev/null +++ b/src/helpers/format/format-conference-provider.spec.ts @@ -0,0 +1,14 @@ +import { settings } from '../settings'; +import { formatConferenceProvider } from './format-conference-provider'; + +describe('formatConferenceProvider', () => { + it.each(['foo', 'https://', 'https://foo.com'])( + 'yields null with %s', + input => expect(formatConferenceProvider(input)).toStrictEqual(null) + ); + + it('returns title when a valid provider is found', () => { + const [[url, name]] = Object.entries(settings.conference_providers); + expect(formatConferenceProvider(`https://${url}`)).toStrictEqual(name); + }); +}); diff --git a/src/helpers/format/format-conference-provider.ts b/src/helpers/format/format-conference-provider.ts new file mode 100644 index 00000000..529d75d2 --- /dev/null +++ b/src/helpers/format/format-conference-provider.ts @@ -0,0 +1,11 @@ +import { settings } from '../settings'; + +//get name of provider from url +export function formatConferenceProvider(url: string) { + const urlParts = url.split('/'); + if (urlParts.length < 2) return null; + const provider = Object.keys(settings.conference_providers).filter(domain => + urlParts[2].endsWith(domain) + ); + return provider.length ? settings.conference_providers[provider[0]] : null; +} diff --git a/src/helpers/format/format-directions-url.spec.ts b/src/helpers/format/format-directions-url.spec.ts new file mode 100644 index 00000000..24804549 --- /dev/null +++ b/src/helpers/format/format-directions-url.spec.ts @@ -0,0 +1,34 @@ +import { formatDirectionsUrl } from './format-directions-url'; + +describe('formatDirectionsUrl', () => { + const { formatted_address, latitude, longitude } = { + formatted_address: 'foo', + latitude: 1, + longitude: 1, + }; + + const baseUrl = 'https://www.google.com/maps?saddr=Current+Location&daddr='; + const iosBaseUrl = 'maps://?saddr=Current+Location&daddr='; + + it.each` + input | expected + ${{ formatted_address }} | ${'foo'} + ${{ formatted_address, latitude }} | ${'foo'} + ${{ formatted_address, latitude, longitude }} | ${'1%2C1&q=foo'} + `('yields $expected with $input', ({ input, expected }) => { + //test non-ios + expect(formatDirectionsUrl(input)).toStrictEqual(baseUrl + expected); + + //change platform to ios - TODO: platform is deprecated + Object.defineProperty(navigator, 'platform', { + value: 'iPhone', + writable: true, + }); + + //test ios + expect(formatDirectionsUrl(input)).toStrictEqual(iosBaseUrl + expected); + + //reset + (navigator as any).platform = undefined; + }); +}); diff --git a/src/helpers/format/format-directions-url.ts b/src/helpers/format/format-directions-url.ts new file mode 100644 index 00000000..5f4b1dcd --- /dev/null +++ b/src/helpers/format/format-directions-url.ts @@ -0,0 +1,26 @@ +//send back a string url to get directions with the appropriate provider +export function formatDirectionsUrl({ + formatted_address, + latitude, + longitude, +}: { + formatted_address: string; + latitude?: number; + longitude?: number; +}) { + //create a link for directions + const iOS = navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform); + const baseURL = iOS ? 'maps://' : 'https://www.google.com/maps'; + const params: { saddr: string; daddr?: string; q?: string } = { + saddr: 'Current Location', + }; + + if (latitude && longitude) { + params['daddr'] = [latitude, longitude].join(); + params['q'] = formatted_address; + } else { + params['daddr'] = formatted_address; + } + + return `${baseURL}?${new URLSearchParams(params)}`; +} diff --git a/src/helpers/format/format-feedback-email.spec.ts b/src/helpers/format/format-feedback-email.spec.ts new file mode 100644 index 00000000..b9f8688d --- /dev/null +++ b/src/helpers/format/format-feedback-email.spec.ts @@ -0,0 +1,36 @@ +import { settings } from '../settings'; +import { getQueryString } from '../query-string'; +import { formatUrl } from './format-url'; +import { formatFeedbackEmail } from '.'; +import { Meeting } from '../../types/Meeting'; + +jest.mock('./format-url', () => ({ + formatUrl: jest.fn().mockReturnValue('https://foo.com'), +})); + +jest.mock('../query-string'); + +const mockedGetQueryString = jest.mocked(getQueryString); + +//can't use mock factories with outside scoped variables +mockedGetQueryString.mockReturnValue(settings.defaults); + +//TODO: Only requiring the parts needed for this test, should +//probably integrate fixtures. +const mockMeeting = { + name: 'Foo', + edit_url: 'row 1', +} as unknown as Meeting; + +const mockEmails = ['foo@bar.com', 'baz@qux.com']; + +describe('formatFeedbackEmail', () => { + it('works with one or more emails', () => { + expect(formatFeedbackEmail([mockEmails[0]], mockMeeting)).toStrictEqual( + 'mailto:foo@bar.com?subject=Meeting Feedback%3A Foo&body=%0A%0A%0A-----%0APublic URL%3A https%3A%2F%2Ffoo.com%0AEdit URL%3A row 1' + ); + expect(formatFeedbackEmail(mockEmails, mockMeeting)).toStrictEqual( + 'mailto:foo@bar.com,baz@qux.com?subject=Meeting Feedback%3A Foo&body=%0A%0A%0A-----%0APublic URL%3A https%3A%2F%2Ffoo.com%0AEdit URL%3A row 1' + ); + }); +}); diff --git a/src/helpers/format/format-feedback-email.ts b/src/helpers/format/format-feedback-email.ts new file mode 100644 index 00000000..795425ae --- /dev/null +++ b/src/helpers/format/format-feedback-email.ts @@ -0,0 +1,35 @@ +import { Meeting } from '../../types/Meeting'; +import { getQueryString } from '../query-string'; +import { strings } from '../settings'; +import { formatArray } from './format-array'; +import { formatUrl } from './format-url'; + +//send back a mailto link to a feedback email +export function formatFeedbackEmail( + feedback_emails: TSMLReactConfig['feedback_emails'], + meeting: Meeting +) { + //remove extra query params from meeting URL + const input = getQueryString(); + const meetingUrl = formatUrl({ meeting: input.meeting }); + + //build message + const lines = [ + ``, + '', + '', + '-----', + strings.email_public_url.replace('%url%', meetingUrl), + ]; + if (meeting.edit_url) { + lines.push(strings.email_edit_url.replace('%url%', meeting.edit_url)); + } + + //build mailto link + return `mailto:${formatArray(feedback_emails).join()}?${new URLSearchParams({ + subject: strings.email_subject.replace('%name%', meeting.name), + body: lines.join('\n'), + }) + .toString() + .replaceAll('+', ' ')}`; +} diff --git a/src/helpers/format/format-ics.spec.ts b/src/helpers/format/format-ics.spec.ts new file mode 100644 index 00000000..8099dc32 --- /dev/null +++ b/src/helpers/format/format-ics.spec.ts @@ -0,0 +1,99 @@ +import moment from 'moment-timezone'; +import { formatIcs } from '.'; +import { Meeting } from '../../types/Meeting'; + +//TODO: Only requiring the parts needed for this test, should +//probably integrate fixtures. +const mockMeeting = { + name: 'Foo Meeting', + start: moment('2022-01-01T00:00:00.000Z'), + timezone: 'America/New_York', +} as Meeting; + +describe('formatIcs', () => { + it('works with minimal data', () => { + formatIcs(mockMeeting); + + expect(location).toStrictEqual( + 'data:text/calendar;charset=utf8,BEGIN:VCALENDAR%0AVERSION:2.0%0ABEGIN:VEVENT%0ASUMMARY:Foo%20Meeting%0ADTSTART:20220101T000000Z%0ADTSTART;TZID=/America/New_York:20211231T190000%0ADTEND:20220101T010000Z%0ADTEND;TZID=/America/New_York:20211231T200000%0AEND:VEVENT%0AEND:VCALENDAR' + ); + }); + + it('works with end time', () => { + formatIcs({ + ...mockMeeting, + end: moment('2022-01-01T00:00:00.000Z'), + }); + + expect(location).toStrictEqual( + 'data:text/calendar;charset=utf8,BEGIN:VCALENDAR%0AVERSION:2.0%0ABEGIN:VEVENT%0ASUMMARY:Foo%20Meeting%0ADTSTART:20220101T000000Z%0ADTSTART;TZID=/America/New_York:20211231T190000%0ADTEND:20220101T000000Z%0ADTEND;TZID=/America/New_York:20211231T190000%0AEND:VEVENT%0AEND:VCALENDAR' + ); + }); + + it('works with isInPerson', () => { + formatIcs({ + ...mockMeeting, + isInPerson: true, + formatted_address: '123 Foo Street', + }); + + expect(location).toStrictEqual( + 'data:text/calendar;charset=utf8,BEGIN:VCALENDAR%0AVERSION:2.0%0ABEGIN:VEVENT%0ASUMMARY:Foo%20Meeting%0ADTSTART:20220101T000000Z%0ADTSTART;TZID=/America/New_York:20211231T190000%0ADTEND:20220101T010000Z%0ADTEND;TZID=/America/New_York:20211231T200000%0ALOCATION:123%20Foo%20Street%0AEND:VEVENT%0AEND:VCALENDAR' + ); + }); + + it('works with location NOT in person', () => { + formatIcs({ + ...mockMeeting, + formatted_address: '123 Foo Street', + location: 'Foo Location', + }); + + expect(location).toStrictEqual( + 'data:text/calendar;charset=utf8,BEGIN:VCALENDAR%0AVERSION:2.0%0ABEGIN:VEVENT%0ASUMMARY:Foo%20Meeting%0ADTSTART:20220101T000000Z%0ADTSTART;TZID=/America/New_York:20211231T190000%0ADTEND:20220101T010000Z%0ADTEND;TZID=/America/New_York:20211231T200000%0ALOCATION:Foo%20Location%0AEND:VEVENT%0AEND:VCALENDAR' + ); + }); + + it('works with location AND in person', () => { + formatIcs({ + ...mockMeeting, + isInPerson: true, + formatted_address: '123 Foo Street', + location: 'Foo Location', + }); + + expect(location).toStrictEqual( + 'data:text/calendar;charset=utf8,BEGIN:VCALENDAR%0AVERSION:2.0%0ABEGIN:VEVENT%0ASUMMARY:Foo%20Meeting%0ADTSTART:20220101T000000Z%0ADTSTART;TZID=/America/New_York:20211231T190000%0ADTEND:20220101T010000Z%0ADTEND;TZID=/America/New_York:20211231T200000%0ALOCATION:123%20Foo%20Street%0ADESCRIPTION:Foo%20Location%0AEND:VEVENT%0AEND:VCALENDAR' + ); + }); + + it('works with lat/long', () => { + formatIcs({ + ...mockMeeting, + isInPerson: true, + formatted_address: '123 Foo Street', + location: 'Foo Location', + latitude: 1, + longitude: 1, + }); + + expect(location).toStrictEqual( + 'data:text/calendar;charset=utf8,BEGIN:VCALENDAR%0AVERSION:2.0%0ABEGIN:VEVENT%0ASUMMARY:Foo%20Meeting%0ADTSTART:20220101T000000Z%0ADTSTART;TZID=/America/New_York:20211231T190000%0ADTEND:20220101T010000Z%0ADTEND;TZID=/America/New_York:20211231T200000%0ALOCATION:123%20Foo%20Street%0AGEO:1;1%0ADESCRIPTION:Foo%20Location%0AEND:VEVENT%0AEND:VCALENDAR' + ); + }); + + it('works with conference provider', () => { + formatIcs({ + ...mockMeeting, + conference_provider: 'foo', + conference_url: 'https://foo.com', + }); + + expect(location).toStrictEqual( + 'data:text/calendar;charset=utf8,BEGIN:VCALENDAR%0AVERSION:2.0%0ABEGIN:VEVENT%0ASUMMARY:Foo%20Meeting%0ADTSTART:20220101T000000Z%0ADTSTART;TZID=/America/New_York:20211231T190000%0ADTEND:20220101T010000Z%0ADTEND;TZID=/America/New_York:20211231T200000%0AURL:https://foo.com%0AEND:VEVENT%0AEND:VCALENDAR' + ); + }); + + //TODO: Want to be tactful with other tests dealing with user agent here + it.todo('works in IE'); +}); diff --git a/src/helpers/format/format-ics.ts b/src/helpers/format/format-ics.ts new file mode 100644 index 00000000..fea54c0d --- /dev/null +++ b/src/helpers/format/format-ics.ts @@ -0,0 +1,85 @@ +import { Meeting } from '../../types/Meeting'; + +//format ICS file for add to calendar +export function formatIcs(meeting: Meeting) { + const fmt = 'YYYYMMDDTHHmmss'; + + const url = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'BEGIN:VEVENT', + `SUMMARY:${meeting.name}`, + `DTSTART:${meeting.start.clone().tz('UTC').format(fmt)}Z`, + `DTSTART;TZID=/${meeting.timezone}:${meeting.start.format(fmt)}`, + 'END:VEVENT', + 'END:VCALENDAR', + ]; + + if (meeting.end) { + url.splice( + -2, + 0, + `DTEND:${meeting.end.clone().tz('UTC').format(fmt)}Z`, + `DTEND;TZID=/${meeting.timezone}:${meeting.end.format(fmt)}` + ); + } else { + url.splice( + -2, + 0, + `DTEND:${meeting.start.clone().add(1, 'hour').tz('UTC').format(fmt)}Z`, + `DTEND;TZID=/${meeting.timezone}:${meeting.start + .clone() + .add(1, 'hour') + .format(fmt)}` + ); + } + + //start building notes + const notes = [ + meeting.conference_url_notes, + meeting.conference_phone_notes, + meeting.notes, + meeting.location_notes, + ]; + + if (meeting.isInPerson) { + //address for in-person meetings + url.splice( + -2, + 0, + `LOCATION:${meeting.formatted_address.replaceAll(',', ';')}` + ); + if (meeting.location) { + notes.push(meeting.location); + } + if (meeting.latitude && meeting.longitude) { + url.splice(-2, 0, `GEO:${meeting.latitude};${meeting.longitude}`); + } + } else if (meeting.location) { + //location name + url.splice(-2, 0, `LOCATION:${meeting.location}`); + } + + //add notes if present + const notesString = notes.filter(e => e).join('\\n'); + if (notesString) { + url.splice(-2, 0, `DESCRIPTION:${notesString}`); + } + + //add URL if present + if (meeting.conference_provider) { + url.splice(-2, 0, `URL:${meeting.conference_url.replaceAll('&', '&')}`); + } + + const urlString = url.join('\n'); + + if (/msie\s|trident\/|edge\//i.test(window.navigator.userAgent)) { + //open/save link in IE and Edge + const blob = new Blob([urlString], { type: 'text/calendar;charset=utf-8' }); + window.navigator.msSaveBlob(blob, 'download.ics'); + } else { + //open/save link in modern browsers + const uri = `data:text/calendar;charset=utf8,${urlString}`; + window.location = encodeURI(uri) as unknown as Location; + } +} diff --git a/src/helpers/format/format-slug.spec.ts b/src/helpers/format/format-slug.spec.ts new file mode 100644 index 00000000..3b349233 --- /dev/null +++ b/src/helpers/format/format-slug.spec.ts @@ -0,0 +1,31 @@ +import { formatSlug } from '.'; + +describe('formatSlug', () => { + it('removes accents', () => { + const actual = 'åàáãäâèéëêìíïîòóöôùúüûñç·/_,:;'; + const expected = 'aaaaaaeeeeiiiioooouuuunc'; + + expect(formatSlug(actual)).toStrictEqual(expected); + }); + + it('hyphen cases', () => { + const actual = 'Foo Bar Baz'; + const expected = 'foo-bar-baz'; + + expect(formatSlug(actual)).toStrictEqual(expected); + }); + + it('removes invalid chars', () => { + const actual = '!@#$%^&*()'; + const expected = ''; + + expect(formatSlug(actual)).toStrictEqual(expected); + }); + + it('removes whitespace and leading / trailing hyphens', () => { + const actual = ' -foo-bar- '; + const expected = 'foo-bar'; + + expect(formatSlug(actual)).toStrictEqual(expected); + }); +}); diff --git a/src/helpers/format/format-slug.ts b/src/helpers/format/format-slug.ts new file mode 100644 index 00000000..a3d4f01d --- /dev/null +++ b/src/helpers/format/format-slug.ts @@ -0,0 +1,19 @@ +//turn Mountain View into mountain-view +export function formatSlug(str: string) { + str = str.trim().toLowerCase(); + + // remove accents, swap ñ for n, etc + const from = 'åàáãäâèéëêìíïîòóöôùúüûñç·/_,:;'; + const to = 'aaaaaaeeeeiiiioooouuuunc------'; + + for (let i = 0, l = from.length; i < l; i++) { + str = str.replace(new RegExp(from.charAt(i), 'g'), to.charAt(i)); + } + + return str + .replace(/[^a-z0-9 -]/g, '') // remove invalid chars + .replace(/\s+/g, '-') // collapse whitespace and replace by - + .replace(/-+/g, '-') // collapse dashes + .replace(/^-+/, '') // trim - from start of text + .replace(/-+$/, ''); // trim - from end of text +} diff --git a/src/helpers/format/format-url.spec.ts b/src/helpers/format/format-url.spec.ts new file mode 100644 index 00000000..031a945a --- /dev/null +++ b/src/helpers/format/format-url.spec.ts @@ -0,0 +1,34 @@ +import { formatUrl } from '.'; + +describe('formatUrl', () => { + it('works with no params', () => { + expect(formatUrl({})).toStrictEqual('https://test.com/'); + }); + + it('works with filters', () => { + expect( + formatUrl({ + distance: [1], + region: ['foo'], + time: ['night'], + type: ['online'], + weekday: ['monday'], + }) + ).toStrictEqual( + 'https://test.com/?region=foo&distance=1&weekday=monday&time=night&type=online' + ); + }); + + it('works with params', () => { + expect( + formatUrl({ + meeting: 'foo', + mode: 'location', + search: 'bar', + view: 'map', + }) + ).toStrictEqual( + 'https://test.com/?search=bar&mode=location&view=map&meeting=foo' + ); + }); +}); diff --git a/src/helpers/format/format-url.ts b/src/helpers/format/format-url.ts new file mode 100644 index 00000000..d9dad0a1 --- /dev/null +++ b/src/helpers/format/format-url.ts @@ -0,0 +1,35 @@ +import { settings } from '../settings'; + +//format an internal link with correct query params +export function formatUrl(input: Partial) { + const query = {}; + + //distance, region, time, type, and weekday + settings.filters + .filter(filter => typeof input[filter] !== 'undefined') + .filter(filter => input[filter]?.length) + .forEach(filter => { + // @ts-expect-error TODO + query[filter] = input[filter].join('/'); + }); + + //meeting, mode, search, view + settings.params + .filter(param => typeof input[param] !== 'undefined') + .filter(param => input[param] !== settings.defaults[param]) + .forEach(param => { + // @ts-expect-error TODO + query[param] = input[param]; + }); + + //create a query string with only values in use + const queryString = new URLSearchParams(query) + .toString() + .replace(/%2F/g, '/') + .replace(/%20/g, '+') + .replace(/%2C/g, ','); + + const [path] = window.location.href.split('?'); + + return `${path}${!!queryString.length ? `?${queryString}` : ''}`; +} diff --git a/src/helpers/format/index.ts b/src/helpers/format/index.ts new file mode 100644 index 00000000..fb960709 --- /dev/null +++ b/src/helpers/format/index.ts @@ -0,0 +1,9 @@ +export * from './format-address'; +export * from './format-array'; +export * from './format-classes'; +export * from './format-conference-provider'; +export * from './format-directions-url'; +export * from './format-feedback-email'; +export * from './format-ics'; +export * from './format-slug'; +export * from './format-url';