Skip to content

Commit

Permalink
feat(billable-metric): allow to edit groups and warn on change
Browse files Browse the repository at this point in the history
  • Loading branch information
ansmonjol committed Sep 6, 2023
1 parent bc6f67d commit 5ca3327
Show file tree
Hide file tree
Showing 12 changed files with 803 additions and 61 deletions.
2 changes: 1 addition & 1 deletion cypress/e2e/10-resources/t50-edit-plan.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe('Edit plan', () => {
cy.get('[data-test="remove-charge"]').should('exist').and('not.be.disabled')
cy.get('[data-test="open-charge"]').first().click()
cy.get('input[name="chargeModel"]').should('be.disabled')
cy.get('input[name="properties.amount"]').should('be.disabled')
cy.get('input[name="properties.amount"]').should('not.be.disabled')
cy.get('[data-test="submit"]').should('be.disabled')
cy.get('[data-test="open-charge"]').first().click()

Expand Down
6 changes: 6 additions & 0 deletions ditto/base.json
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,12 @@
"text_64e620bca31226337ffc62b9": "Add all groups",
"text_64e620bca31226337ffc62bb": "Groups added: {{count}} / {{total}}",
"text_64e6211f8fcca2366dc69005": "Search and select the value to add",
"text_64f2044bd3655501184fe142": "Save billable metric groups’ edition",
"text_64f2044bd3655501184fe143": "This billable metric is linked to {{plansCount}} plans and {{subscriptionsCount}} subscriptions. ",
"text_64f2044bd3655501184fe144": "Removed values won't be included in charges for the associated plans, and no charges will apply to recorded events",
"text_64f2044bd3655501184fe145": "Added values won't affect associated plan charges. The default charge level price will apply to all recorded events related to those new values.",
"text_64f2044bd3655501184fe147": "Save groups edition",
"text_64f1e90251fc8c40b9174943": "The group has structural changes that differ from the current setup. To prevent any impact on the {{plansCount}} plans and {{subscriptionsCount}} subscriptions associated with this billable metric, we will remove the corresponding group values from those plans. New events will be charged according to the default price. Are you okay with proceeding with this action?",
"text_643e592657fc1ba5ce110b9e": "Add a spending minimum",
"text_64463aaa34904c00a23be4f7": "True-up",
"text_643e592657fc1ba5ce110c30": "Spending minimum",
Expand Down
3 changes: 0 additions & 3 deletions ditto/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -257,9 +257,6 @@ sources:
- name: ⚙️ [WIP] - Customer - Add tax_number on customer
id: 648053ecf72671011f9365ff
fileName: ⚙️ [WIP] - Customer - Add tax_number on customer
- name: Subscription at drawer fix
id: 648b171fcdcc85696b5d1cd4
fileName: Subscription at drawer fix
- name: Add missing keys
id: 64999d7a720a1412d1a72cf6
fileName: Add missing keys
Expand Down
9 changes: 3 additions & 6 deletions ditto/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,6 @@ module.exports = {
"project_626162c105cb2c00e673c696": {
"base": require('./customers---edit--delete-a-customer__base.json')
},
"ditto_component_library": {
"base": require('./ditto-component-library__base.json')
},
"project_623b3ac9459a5d00df324533": {
"base": require('./documentation-asset__base.json')
},
Expand Down Expand Up @@ -259,10 +256,10 @@ module.exports = {
"project_6271200612648800e9bdfd47": {
"base": require('./settings---webhooks-in-app__base.json')
},
"project_648b171fcdcc85696b5d1cd4": {
"base": require('./subscription-at-drawer-fix__base.json')
},
"project_642a94e4409e3692d27eda4c": {
"base": require('./subscription-drawer---external-id-input__base.json')
},
"ditto_component_library": {
"base": {...require('./components__root__base.json')}
}
}
2 changes: 1 addition & 1 deletion src/components/WarningDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { forwardRef } from 'react'
import { Dialog, DialogProps, Button, DialogRef } from '~/components/designSystem'
import { useInternationalization } from '~/hooks/core/useInternationalization'

enum WarningDialogMode {
export enum WarningDialogMode {
info = 'info',
danger = 'danger',
}
Expand Down
35 changes: 3 additions & 32 deletions src/components/billableMetrics/BillableMetricCodeSnippet.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AggregationTypeEnum, CreateBillableMetricInput } from '~/generated/graphql'
import { CodeSnippet } from '~/components/CodeSnippet'
import { envGlobalVar } from '~/core/apolloClient'
import { isGroupValid, isOneDimension, isTwoDimension } from '~/core/utils/BMGroupUtils'

const { apiUrl } = envGlobalVar()

Expand All @@ -9,38 +10,8 @@ const getSnippets = (billableMetric?: CreateBillableMetricInput) => {

const { aggregationType, code, fieldName, group, recurring } = billableMetric

const isValidJSON = (string: string) => {
try {
JSON.parse(string)
} catch (e) {
return false
}

return true
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isOneDimension = (object: any): boolean => {
if (!object) return false

return !!object.key && !!object.values && !!object.values.length
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isTwoDimension = (object: any): boolean => {
if (!object) return false

return (
!!object.key &&
!!object.values &&
!!object.values.length &&
!!object.values[0] &&
!!object.values[0].name &&
!!object.values[0].values &&
!!object.values[0].values.length
)
}

const hasGroup = !!group && group !== '{}' && isValidJSON(group)
const parsedGroup = !!hasGroup && JSON.parse(group)
const hasGroup = isGroupValid(JSON.stringify(group))
const parsedGroup = !!hasGroup && JSON.parse(JSON.stringify(group))
const groupDimension =
hasGroup && isTwoDimension(parsedGroup) ? 2 : isOneDimension(parsedGroup) ? 1 : 0
const groupDimensionMessage = `${
Expand Down
64 changes: 64 additions & 0 deletions src/components/billableMetrics/EditBillableMetricGroupDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { forwardRef, useImperativeHandle, useRef, useState } from 'react'

import { DialogRef } from '~/components/designSystem'
import { WarningDialog, WarningDialogMode } from '~/components/WarningDialog'
import { useInternationalization } from '~/hooks/core/useInternationalization'

interface EditBillableMetricGroupDialogProps {
mode: WarningDialogMode
onContinue: () => unknown
plansCount?: number
subscriptionsCount?: number
}

export interface EditBillableMetricGroupDialogRef {
openDialog: ({
onContinue,
plansCount,
subscriptionsCount,
}: EditBillableMetricGroupDialogProps) => unknown
closeDialog: () => unknown
}

export const EditBillableMetricGroupDialog = forwardRef<EditBillableMetricGroupDialogRef>(
(_, ref) => {
const dialogRef = useRef<DialogRef>(null)
const { translate } = useInternationalization()
const [data, setData] = useState<EditBillableMetricGroupDialogProps | undefined>(undefined)

const { mode, onContinue, plansCount, subscriptionsCount } = data || {}

useImperativeHandle(ref, () => ({
openDialog: (infos) => {
setData(infos)
dialogRef.current?.openDialog()
},
closeDialog: () => dialogRef.current?.closeDialog(),
}))

return (
<WarningDialog
mode={mode}
ref={dialogRef}
title={translate('text_64f2044bd3655501184fe142')}
description={
mode === WarningDialogMode.danger ? (
translate('text_64f1e90251fc8c40b9174943', { plansCount, subscriptionsCount })
) : (
<>
{translate('text_64f2044bd3655501184fe143', { plansCount, subscriptionsCount })}
<ul>
<li>{translate('text_64f2044bd3655501184fe144')}</li>
<li>{translate('text_64f2044bd3655501184fe145')}</li>
</ul>
</>
)
}
onContinue={async () => onContinue && onContinue()}
continueText={translate('text_64f2044bd3655501184fe147')}
/>
)
}
)

EditBillableMetricGroupDialog.displayName = 'EditBillableMetricGroupDialog'
14 changes: 3 additions & 11 deletions src/components/plans/ChargeAccordion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -525,13 +525,11 @@ export const ChargeAccordion = memo(
<Tooltip
placement="top-end"
title={translate('text_63aa085d28b8510cd46443ff')}
disableHoverListener={disabled}
>
<Button
size="small"
icon="trash"
variant="quaternary"
disabled={disabled}
onClick={() => {
// Remove the default charge
handleUpdate('properties', undefined)
Expand All @@ -551,7 +549,6 @@ export const ChargeAccordion = memo(
currency={currency}
formikProps={formikProps}
index={index}
disabled={disabled}
propertyCursor="properties"
premiumWarningDialogRef={premiumWarningDialogRef}
valuePointer={localCharge.properties}
Expand Down Expand Up @@ -605,12 +602,10 @@ export const ChargeAccordion = memo(
<Tooltip
placement="top-end"
title={translate('text_63aa085d28b8510cd46443ff')}
disableHoverListener={disabled}
>
<Button
size="small"
icon="trash"
disabled={disabled}
variant="quaternary"
onClick={() => {
const existingGroupProperties = [
Expand All @@ -631,7 +626,6 @@ export const ChargeAccordion = memo(
currency={currency}
formikProps={formikProps}
index={index}
disabled={disabled}
propertyCursor={`groupProperties.${groupPropertyIndex}.values`}
premiumWarningDialogRef={premiumWarningDialogRef}
valuePointer={
Expand Down Expand Up @@ -701,10 +695,9 @@ export const ChargeAccordion = memo(
startIcon="plus"
variant="quaternary"
disabled={
disabled ||
((localCharge.groupProperties?.length || 0) ===
(localCharge.groupProperties?.length || 0) ===
(localCharge.billableMetric.flatGroups?.length || 0) &&
!!localCharge.properties)
!!localCharge.properties
}
onClick={() => {
setShowAddGroup(true)
Expand All @@ -724,9 +717,8 @@ export const ChargeAccordion = memo(
startIcon="plus"
variant="quaternary"
disabled={
disabled ||
(localCharge.groupProperties?.length || 0) ===
(localCharge.billableMetric.flatGroups?.length || 0)
(localCharge.billableMetric.flatGroups?.length || 0)
}
onClick={() => {
const newGroupProperties = [
Expand Down
146 changes: 146 additions & 0 deletions src/core/utils/BMGroupUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
export const GroupLevelEnum = {
NoChange: 'NoChange',
AddOrRemove: 'AddOrRemove',
StructuralChange: 'StructuralChange',
} as const

type determineGroupDiffLevelReturnType =
| (typeof GroupLevelEnum)[keyof typeof GroupLevelEnum]
| undefined
type oneDimensionGroupType = {
key: string
values: string[]
}
type twoDimensionGroupType = {
key: string
values: {
name: string
key: string
values: string[]
}[]
}
type groupType = oneDimensionGroupType | twoDimensionGroupType | {}

const isValidJSON = (string: string) => {
try {
JSON.parse(string)
} catch (e) {
return false
}

return true
}

const isContainingObjectValues = (object: twoDimensionGroupType): boolean => {
if (!object) return false
return object.values?.every((value) => typeof value === 'object')
}

const isContainingStringValues = (object: oneDimensionGroupType): boolean => {
if (!object) return false
return object.values?.every((value) => typeof value === 'string')
}

export const isOneDimension = (object: oneDimensionGroupType): boolean => {
if (!object) return false

return (
!!object.key && !!object.values && !!object.values.length && !!isContainingStringValues(object)
)
}

export const isTwoDimension = (object: twoDimensionGroupType): boolean => {
if (!object) return false

return (
!!object.key && !!object.values && !!object.values.length && !!isContainingObjectValues(object)
)
}

export const isGroupValid = (object: groupType) =>
!!object && object !== '{}' && isValidJSON(JSON.stringify(object))

const areGroupsOneDimension = (
group1: oneDimensionGroupType,
group2: oneDimensionGroupType
): boolean => {
return isOneDimension(group1) && isOneDimension(group2)
}

const areGroupsTwoDimension = (
group1: twoDimensionGroupType,
group2: twoDimensionGroupType
): boolean => {
return isTwoDimension(group1) && isTwoDimension(group2)
}

export const determineGroupDiffLevel: (
group1: groupType | string,
group2: groupType | string
) => determineGroupDiffLevelReturnType = (group1 = {}, group2 = {}) => {
// Groups can be empty, replace them with empty object
if (group1 === '') group1 = '{}'
if (group2 === '') group2 = '{}'

// Key stringify/parse to shallow copy the value
const parsedGroup1 =
typeof group1 === 'string' ? JSON.parse(group1) : JSON.parse(JSON.stringify(group1))
const parsedGroup2 =
typeof group2 === 'string' ? JSON.parse(group2) : JSON.parse(JSON.stringify(group2))

// Check if one of the groups are both valid
if (!isGroupValid(parsedGroup1) || !isGroupValid(parsedGroup2)) {
return GroupLevelEnum.StructuralChange
}

// Check if groups have the same dimension
if (
!(
areGroupsTwoDimension(parsedGroup1, parsedGroup2) ||
areGroupsOneDimension(parsedGroup1, parsedGroup2)
)
) {
return GroupLevelEnum.StructuralChange
}

// Check if group has value change (added or removed)
if (areGroupsTwoDimension(parsedGroup1, parsedGroup2)) {
const parsedGroup1ValuesKeys = (parsedGroup1 as twoDimensionGroupType).values
.map((value) => value.key)
.sort()
const parsedGroup2ValuesKeys = (parsedGroup2 as twoDimensionGroupType).values
.map((value) => value.key)
.sort()
const parsedGroup1ValuesNames = (parsedGroup1 as twoDimensionGroupType).values
.map((value) => value.name)
.sort()
const parsedGroup2ValuesNames = (parsedGroup2 as twoDimensionGroupType).values
.map((value) => value.name)
.sort()
const parsedGroup1ValuesValues = (parsedGroup1 as twoDimensionGroupType).values
.map((value) => value.values.sort())
.sort()
const parsedGroup2ValuesValues = (parsedGroup2 as twoDimensionGroupType).values
.map((value) => value.values.sort())
.sort()

if (
parsedGroup1.key !== parsedGroup2.key ||
JSON.stringify(parsedGroup1ValuesKeys) !== JSON.stringify(parsedGroup2ValuesKeys) ||
JSON.stringify(parsedGroup1ValuesNames) !== JSON.stringify(parsedGroup2ValuesNames) ||
JSON.stringify(parsedGroup1ValuesValues) !== JSON.stringify(parsedGroup2ValuesValues)
)
return GroupLevelEnum.AddOrRemove
} else if (areGroupsOneDimension(parsedGroup1, parsedGroup2)) {
const parsedGroup1Values = (parsedGroup1 as oneDimensionGroupType).values.sort()
const parsedGroup2Values = (parsedGroup2 as oneDimensionGroupType).values.sort()

if (
parsedGroup1.key !== parsedGroup2.key ||
JSON.stringify(parsedGroup1Values) !== JSON.stringify(parsedGroup2Values)
)
return GroupLevelEnum.AddOrRemove
}

return GroupLevelEnum.NoChange
}
Loading

0 comments on commit 5ca3327

Please sign in to comment.