From 99b7ae645e8c3f913e48710b7316e7e67fbef514 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Tue, 18 Jun 2024 13:03:03 +0800 Subject: [PATCH 1/6] feat: campaign form --- .../4ee4a9ff-f61b-476f-9140-27308934024c.json | 34 ++ .../b06e2425-70c2-47db-bafa-75a3ca349541.json | 34 ++ packages/drafts-realm/campaign-form.gts | 512 ++++++++++++++++++ 3 files changed, 580 insertions(+) create mode 100644 packages/drafts-realm/CampaignForm/4ee4a9ff-f61b-476f-9140-27308934024c.json create mode 100644 packages/drafts-realm/CampaignForm/b06e2425-70c2-47db-bafa-75a3ca349541.json create mode 100644 packages/drafts-realm/campaign-form.gts diff --git a/packages/drafts-realm/CampaignForm/4ee4a9ff-f61b-476f-9140-27308934024c.json b/packages/drafts-realm/CampaignForm/4ee4a9ff-f61b-476f-9140-27308934024c.json new file mode 100644 index 0000000000..a5f880038a --- /dev/null +++ b/packages/drafts-realm/CampaignForm/4ee4a9ff-f61b-476f-9140-27308934024c.json @@ -0,0 +1,34 @@ +{ + "data": { + "type": "card", + "attributes": { + "name": "Advertisement Campaign Form", + "status": "Planned", + "active": true, + "type": "Advertisement", + "description": "ssssss", + "start_date": "2024-06-30", + "end_date": "2024-06-30", + "number_sent": "200003212", + "expected_response_percentage": "222.53", + "expected_revenue": "2312", + "budgeted_cost": "1003039", + "actual_cost": "12034044", + "title": null, + "thumbnailURL": null + }, + "relationships": { + "parent_campaign": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../campaign-form", + "name": "CampaignForm" + } + } + } +} \ No newline at end of file diff --git a/packages/drafts-realm/CampaignForm/b06e2425-70c2-47db-bafa-75a3ca349541.json b/packages/drafts-realm/CampaignForm/b06e2425-70c2-47db-bafa-75a3ca349541.json new file mode 100644 index 0000000000..70570d3923 --- /dev/null +++ b/packages/drafts-realm/CampaignForm/b06e2425-70c2-47db-bafa-75a3ca349541.json @@ -0,0 +1,34 @@ +{ + "data": { + "type": "card", + "attributes": { + "name": "Test Email Campaign 2", + "status": "Planned", + "active": false, + "type": "Advertisement", + "description": "This is a test form", + "start_date": "2024-06-22", + "end_date": "2024-06-29", + "number_sent": "1000", + "expected_response_percentage": "100", + "expected_revenue": "2000", + "budgeted_cost": "1000", + "actual_cost": "1000", + "title": null, + "thumbnailURL": null + }, + "relationships": { + "parent_campaign": { + "links": { + "self": "./4ee4a9ff-f61b-476f-9140-27308934024c" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../campaign-form", + "name": "CampaignForm" + } + } + } +} \ No newline at end of file diff --git a/packages/drafts-realm/campaign-form.gts b/packages/drafts-realm/campaign-form.gts new file mode 100644 index 0000000000..d91296d37d --- /dev/null +++ b/packages/drafts-realm/campaign-form.gts @@ -0,0 +1,512 @@ +import DateField from 'https://cardstack.com/base/date'; +import BooleanField from 'https://cardstack.com/base/boolean'; +import { + Component, + CardDef, + field, + contains, + StringField, + linksTo, +} from 'https://cardstack.com/base/card-api'; + +import { + FieldContainer, + BoxelSelect, + BoxelInput, +} from '@cardstack/boxel-ui/components'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { parse, format, isBefore, isAfter } from 'date-fns'; +import { fn } from '@ember/helper'; + +const dateFormat = `yyyy-MM-dd`; + +const sanitisedNumber = (inputText: string) => { + const sanitised = inputText + .replace(/ /g, '') + .replace(/,/g, '') + .replace(/%/g, '') + .replace(/RM/g, ''); + return !isNaN(parseFloat(sanitised)) ? parseFloat(sanitised) : 0; +}; + +const nearestDecimal = (num: number, decimalPlaces: number) => { + // https://stackoverflow.com/questions/11832914/how-to-round-to-at-most-2-decimal-places-if-necessary + const factorOfTen = Math.pow(10, decimalPlaces); + return Math.round(num * factorOfTen + Number.EPSILON) / factorOfTen; +}; + +const formatCurrency = ( + num: number | string | null | undefined, + locale: string = 'en-MY', + currency: string = 'MYR', +) => { + if (num === null || num === undefined) { + return ''; + } + + const formatter = new Intl.NumberFormat(locale, { + style: 'currency', + currency: currency, + minimumFractionDigits: 0, // No decimal places + maximumFractionDigits: 0, // No decimal places + }); + + let currentNumber = num; + if (typeof num === 'string') { + currentNumber = parseFloat(num); + } + + return formatter.format(currentNumber as number); +}; + +const formatNumberWithSeparator = ( + num: number | string | null | undefined, + isPercentage = false, +) => { + if (num === null || num === undefined) { + return ''; + } + + let currentNumber = num; + if (typeof num === 'string') { + currentNumber = parseFloat(num); + } + + return `${currentNumber.toLocaleString('en-US')}${isPercentage ? ' %' : ''}`; +}; + +class Isolated extends Component { + +} + +class Embedded extends Component { + +} + +class Edit extends Component { + @tracked name = this.args.model.name; + @tracked selectedStatus = { name: this.args.model.status }; + @tracked selectedActive = this.args.model.active; + @tracked selectedType = { name: this.args.model.type }; + @tracked description = this.args.model.description; + @tracked startDateString = this.args.model.start_date + ? format(this.args.model.start_date, dateFormat) + : null; + @tracked endDateString = this.args.model.end_date + ? format(this.args.model.end_date, dateFormat) + : null; + @tracked numberSentInputValue = formatNumberWithSeparator( + this.args.model.number_sent, + ); + @tracked expectedResponseInputValue = formatNumberWithSeparator( + this.args.model.expected_response_percentage, + true, + ); + @tracked expectedRevenueInputValue = formatCurrency( + this.args.model.expected_revenue, + ); + @tracked budgetedCostInputValue = formatCurrency( + this.args.model.budgeted_cost, + ); + @tracked actualCostInputValue = formatCurrency(this.args.model.actual_cost); + + @action updateName(inputText: string) { + this.name = inputText; + this.args.model.name = inputText; + } + + @action updateStatus(type: { name: string }) { + this.selectedStatus = type; + this.args.model.status = type.name; + } + + @action updateActive() { + this.args.model.active = !this.args.model.active; + } + + @action updateType(type: { name: string }) { + this.selectedType = type; + this.args.model.type = type.name; + } + + @action updateDescription(inputText: string) { + this.description = inputText; + this.args.model.description = inputText; + } + + @action updateStartDate(date: Date) { + // If the end date is set and the new start date is after the end date, update the end date + if (this.args.model.end_date && isAfter(date, this.args.model.end_date)) { + this.args.model.end_date = date; + this.endDateString = format(date, dateFormat); + } + this.args.model.start_date = date; + this.startDateString = format(date, dateFormat); + } + + @action updateEndDate(date: Date) { + // If the start date is set and the new end date is before the start date, update the start date + if ( + this.args.model.start_date && + isBefore(date, this.args.model.start_date) + ) { + this.args.model.start_date = date; + this.startDateString = format(date, dateFormat); + } + this.args.model.end_date = date; + this.endDateString = format(date, dateFormat); + } + + @action parseDateInput(field: string, date: string) { + if (field === 'start_date') { + return this.updateStartDate(parse(date, dateFormat, new Date())); + } + return this.updateEndDate(parse(date, dateFormat, new Date())); + } + + validateOnKeyPress = (event: KeyboardEvent) => { + const eventKey = event.key; + // Allow only numeric characters (0-9) and decimal point (.) + if ( + !/^\d+$/.test(eventKey) && + eventKey !== '.' && + eventKey !== 'Backspace' && + eventKey !== 'Delete' && + eventKey !== 'ArrowLeft' && + eventKey !== 'ArrowRight' && + eventKey !== 'ArrowUp' && + eventKey !== 'ArrowDown' && + eventKey !== 'Tab' + ) { + event.preventDefault(); + } + }; + + @action updateCustomNumberInput( + fieldName: + | 'numberSentInputValue' + | 'expectedResponseInputValue' + | 'expectedRevenueInputValue' + | 'budgetedCostInputValue' + | 'actualCostInputValue', + inputText: string, + ) { + this[fieldName] = inputText; + } + + @action onBlurNumberSent() { + const currentNumber = sanitisedNumber(this.numberSentInputValue); + const nearestRoundNumber = Math.round(currentNumber); + + const formatNumber = nearestRoundNumber; + this.numberSentInputValue = formatNumberWithSeparator(formatNumber); + this.args.model.number_sent = formatNumber.toString(); + } + + @action onBlurExpectedResponse() { + const currentNumber = sanitisedNumber(this.expectedResponseInputValue); + const formatNumber = nearestDecimal(currentNumber, 2); + this.expectedResponseInputValue = formatNumberWithSeparator( + formatNumber, + true, + ); + this.args.model.expected_response_percentage = formatNumber.toString(); + } + + @action onBlurCurrencyField( + inputText: string, + inputValueName: + | 'expectedRevenueInputValue' + | 'budgetedCostInputValue' + | 'actualCostInputValue', + fieldName: 'expected_revenue' | 'budgeted_cost' | 'actual_cost', + ) { + const currentNumber = sanitisedNumber(inputText); + const formatNumber = Math.round(currentNumber); + const numberWithCurrency = formatCurrency(formatNumber); + this[inputValueName] = numberWithCurrency; + this.args.model[fieldName] = formatNumber.toString(); + } + + private campaignStatuses = [ + { name: 'None' }, + { name: 'Planned' }, + { name: 'In Progress' }, + { name: 'Completed' }, + { name: 'Aborted' }, + ]; + + private campaignTypes = [ + { name: 'None' }, + { name: 'Advertisement' }, + { name: 'Email' }, + { name: 'Telemarketing' }, + { name: 'Banner Ads' }, + { name: 'Seminar/Conference' }, + { name: 'Public Relations' }, + { name: 'Partners' }, + { name: 'Referral Program' }, + { name: 'Other' }, + ]; + + +} + +export class CampaignForm extends CardDef { + @field name = contains(StringField); + @field status = contains(StringField); + @field active = contains(BooleanField); + @field type = contains(StringField); + @field parent_campaign = linksTo(() => CampaignForm); + @field description = contains(StringField); + @field start_date = contains(DateField); + @field end_date = contains(DateField); + @field number_sent = contains(StringField); + @field expected_response_percentage = contains(StringField); + @field expected_revenue = contains(StringField); + @field budgeted_cost = contains(StringField); + @field actual_cost = contains(StringField); + + static displayName = 'CampaignForm'; + + static isolated = Isolated; + static embedded = Embedded; + static atom = Embedded; + static edit = Edit; +} From 7e4efba1e1fc4da126787590c08f8b618bf800fb Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Tue, 18 Jun 2024 13:09:30 +0800 Subject: [PATCH 2/6] feat: proper test field initialisation --- packages/drafts-realm/campaign-form.gts | 110 +++++++++++++----------- 1 file changed, 58 insertions(+), 52 deletions(-) diff --git a/packages/drafts-realm/campaign-form.gts b/packages/drafts-realm/campaign-form.gts index d91296d37d..c4b8df303c 100644 --- a/packages/drafts-realm/campaign-form.gts +++ b/packages/drafts-realm/campaign-form.gts @@ -79,75 +79,43 @@ const formatNumberWithSeparator = ( class Isolated extends Component {