Skip to content

Commit a6c5c9b

Browse files
authored
feat: Convert to Contract button in operator UI (#4932)
* feat: Convert to Contract button in operator UI This adds a Convert to Contract button to the operator UI's account page. This button is only visible to read-write operators and is only enabled for free accounts. When clicked, an overlay is shown that allows the user to select a contract start date and confirm the convert to account action. The setVisible and visible props for DeleteAccountOverlay were refactored to be less generic so they could be distinguished from the props used to show ConvertAccountToContractOverlay. There were some auto-fixes from eslint and prettier as well. This also adds a cypress test for the Convert to Contract button, which also tests whether the Delete Account button is enabled when the account is deleteable. Finally, the datepicker was modeled after the one in the TimeTickInput. Because of this, there is an option to select the time as well. Ideally we can update clockface to include a working datepicker without time selection, but until then, this is probably the best we can do. Because the time is not relevant for this component though, I've set it up to not actually do anything when a time is selected. Because of this, the only way to have a time other than 00:00:00 is to enter it manually in the input in the convert to contract overlay.
1 parent bb5d702 commit a6c5c9b

File tree

10 files changed

+340
-14
lines changed

10 files changed

+340
-14
lines changed

cypress/e2e/cloud/operator.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@ describe('Operator Page', () => {
145145
cy.getByTestID('account-view--header').contains('operator1 (1)')
146146
// should not be able to delete undeletable accounts
147147
cy.getByTestID('account-delete--button').should('be.disabled')
148+
// should not be able to convert non-free accounts to contract
149+
cy.getByTestID('account-convert-to-contract--button').should('be.disabled')
148150

149151
// Associated Users Section
150152
cy.getByTestID('associated-users--title').contains('Associated Users')
@@ -187,6 +189,25 @@ describe('Operator Page', () => {
187189

188190
cy.getByTestID('account-view--back-button').click()
189191

192+
cy.getByTestID('account-id')
193+
.last()
194+
.within(() => {
195+
cy.get('a').click()
196+
})
197+
198+
cy.location().should(loc => {
199+
expect(loc.pathname).to.eq('/operator/accounts/678')
200+
})
201+
202+
// should be able to delete deletable accounts
203+
cy.getByTestID('account-delete--button').should('not.be.disabled')
204+
// should be able to convert free accounts to contract
205+
cy.getByTestID('account-convert-to-contract--button').should(
206+
'not.be.disabled'
207+
)
208+
209+
cy.getByTestID('account-view--back-button').click()
210+
190211
cy.getByTestID('accountTab').should('have.class', 'cf-tabs--tab__active')
191212
cy.getByTestID('orgTab').should('not.have.class', 'cf-tabs--tab__active')
192213

cypress/e2e/cloud/operatorRO.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ describe('Operator Page', () => {
7878

7979
// make sure the buttons don't exist on the page
8080
cy.getByTestID('account-delete--button').should('not.exist')
81+
cy.getByTestID('account-convert-to-contract--button').should('not.exist')
8182
cy.getByTestID('remove-user--button').should('not.exist')
8283
cy.getByTestID('page-title').should('contain.text', 'operator1 (1)')
8384

src/operator/account/AccountView.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {AppWrapper, Page} from '@influxdata/clockface'
66
import AppPageHeader from 'src/operator/AppPageHeader'
77
import AssociatedOrgsTable from 'src/operator/account/AssociatedOrgsTable'
88
import AssociatedUsersTable from 'src/operator/account/AssociatedUsersTable'
9+
import ConvertAccountToContractOverlay from 'src/operator/account/ConvertAccountToContractOverlay'
910
import DeleteAccountOverlay from 'src/operator/account/DeleteAccountOverlay'
1011
import AccountViewHeader from 'src/operator/account/AccountViewHeader'
1112
import AccountGrid from 'src/operator/account/AccountGrid'
@@ -30,6 +31,7 @@ const AccountView: FC = () => {
3031
<Page titleTag={accountTitle} testID="account-view--header">
3132
<AppPageHeader title={accountTitle} />
3233
<Page.Contents scrollable={true}>
34+
<ConvertAccountToContractOverlay />
3335
<DeleteAccountOverlay />
3436
<AccountViewHeader />
3537
<AccountGrid />

src/operator/account/AccountViewHeader.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@ import {AccountContext} from 'src/operator/context/account'
1818
import {OperatorContext} from 'src/operator/context/operator'
1919

2020
const AccountViewHeader: FC = () => {
21-
const {account, setVisible, visible} = useContext(AccountContext)
21+
const {
22+
account,
23+
setConvertToContractOverlayVisible,
24+
convertToContractOverlayVisible,
25+
setDeleteOverlayVisible,
26+
deleteOverlayVisible,
27+
} = useContext(AccountContext)
2228
const {hasWritePermissions} = useContext(OperatorContext)
2329

2430
return (
@@ -33,11 +39,28 @@ const AccountViewHeader: FC = () => {
3339
Back to Account List
3440
</Link>
3541
</FlexBox.Child>
42+
{hasWritePermissions && (
43+
<ButtonBase
44+
color={ComponentColor.Primary}
45+
shape={ButtonShape.Default}
46+
onClick={() =>
47+
setConvertToContractOverlayVisible(!convertToContractOverlayVisible)
48+
}
49+
status={
50+
account.type === 'free'
51+
? ComponentStatus.Default
52+
: ComponentStatus.Disabled
53+
}
54+
testID="account-convert-to-contract--button"
55+
>
56+
Convert to Contract
57+
</ButtonBase>
58+
)}
3659
{hasWritePermissions && (
3760
<ButtonBase
3861
color={ComponentColor.Danger}
3962
shape={ButtonShape.Default}
40-
onClick={_e => setVisible(!visible)}
63+
onClick={() => setDeleteOverlayVisible(!deleteOverlayVisible)}
4164
status={
4265
account?.deletable
4366
? ComponentStatus.Default
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.contract-start-date-picker {
2+
top: 50%;
3+
left: 50%;
4+
transform: translate(0, -50%);
5+
}
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import React, {
2+
ChangeEvent,
3+
createRef,
4+
FC,
5+
RefObject,
6+
useContext,
7+
useState,
8+
} from 'react'
9+
import {
10+
Overlay,
11+
Gradients,
12+
Alert,
13+
ComponentColor,
14+
IconFont,
15+
ButtonBase,
16+
ButtonShape,
17+
Appearance,
18+
Button,
19+
ClickOutside,
20+
ComponentSize,
21+
Form,
22+
Popover,
23+
PopoverInteraction,
24+
PopoverPosition,
25+
ButtonRef,
26+
Columns,
27+
Grid,
28+
Input,
29+
InfluxColors,
30+
} from '@influxdata/clockface'
31+
import DatePicker from 'src/shared/components/dateRangePicker/DatePicker'
32+
import {AccountContext} from 'src/operator/context/account'
33+
import {createDateTimeFormatter} from 'src/utils/datetime/formatters'
34+
import {isValidStrictly} from 'src/utils/datetime/validator'
35+
36+
const noOp = () => {}
37+
38+
const ConvertAccountToContractOverlay: FC = () => {
39+
const {
40+
account,
41+
handleConvertAccountToContract,
42+
organizations,
43+
setConvertToContractOverlayVisible,
44+
convertToContractOverlayVisible,
45+
} = useContext(AccountContext)
46+
47+
const convertAccountToContract = () => {
48+
if (!handleStartDateValidation()) {
49+
try {
50+
handleConvertAccountToContract(startDateInput)
51+
setConvertToContractOverlayVisible(false)
52+
} catch {
53+
setConvertToContractOverlayVisible(false)
54+
}
55+
}
56+
}
57+
58+
const dateFormat = 'YYYY-MM-DD'
59+
const formatDate = (date: Date | string) => {
60+
const formatter = createDateTimeFormatter(dateFormat)
61+
return formatter.format(new Date(date))
62+
}
63+
const isValidDate = (date: string) => isValidStrictly(date, dateFormat)
64+
65+
const [startDateInput, setStartDateInput] = useState<string>(
66+
formatDate(new Date())
67+
)
68+
69+
const [isDatePickerOpen, setIsDatePickerOpen] = useState<boolean>(false)
70+
71+
const [
72+
isOnClickOutsideHandlerActive,
73+
setIsOnClickOutsideHandlerActive,
74+
] = useState<boolean>(true)
75+
76+
const triggerRef: RefObject<ButtonRef> = createRef()
77+
78+
const handleInput = (event: ChangeEvent<HTMLInputElement>) => {
79+
setStartDateInput(event.target.value)
80+
}
81+
82+
const handleStartDateValidation = () => {
83+
if (!isValidDate(startDateInput)) {
84+
return `Date format must be ${dateFormat}`
85+
}
86+
return null
87+
}
88+
89+
const getDatePickerDateTime = () => {
90+
let date = new Date(startDateInput)
91+
// Adjust date to ignore timezone since the time isn't needed
92+
const timezoneOffset = date.getTimezoneOffset() * 60000
93+
date = new Date(date.getTime() + timezoneOffset)
94+
return !Number.isNaN(date.valueOf())
95+
? date.toISOString()
96+
: new Date().toISOString()
97+
}
98+
99+
const handleSelectDate = (date: string) => setStartDateInput(formatDate(date))
100+
101+
const showDatePicker = () => setIsDatePickerOpen(true)
102+
const hideDatePicker = () => setIsDatePickerOpen(false)
103+
104+
const toggleDatePicker = () => {
105+
if (isDatePickerOpen) {
106+
hideDatePicker()
107+
} else {
108+
showDatePicker()
109+
}
110+
}
111+
112+
const onClickOutside = () => {
113+
if (isOnClickOutsideHandlerActive) {
114+
hideDatePicker()
115+
}
116+
}
117+
118+
const allowOnClickOutside = () => setIsOnClickOutsideHandlerActive(true)
119+
const suppressOnClickOutside = () => setIsOnClickOutsideHandlerActive(false)
120+
121+
return (
122+
<Overlay
123+
visible={convertToContractOverlayVisible}
124+
renderMaskElement={() => (
125+
<Overlay.Mask gradient={Gradients.DangerDark} style={{opacity: 0.5}} />
126+
)}
127+
testID="delete-overlay"
128+
transitionDuration={0}
129+
>
130+
<Overlay.Container maxWidth={600}>
131+
<Overlay.Header
132+
title="Convert Account to Contract"
133+
style={{color: InfluxColors.White}}
134+
onDismiss={() =>
135+
setConvertToContractOverlayVisible(!convertToContractOverlayVisible)
136+
}
137+
/>
138+
<Overlay.Body>
139+
<Alert color={ComponentColor.Danger} icon={IconFont.AlertTriangle}>
140+
This action cannot be undone
141+
</Alert>
142+
<h4 style={{color: InfluxColors.White}}>
143+
<strong>Warning</strong>
144+
</h4>
145+
<p>
146+
This action will convert the account to an annual contract account.
147+
This will affect the billing for all orgs in the account.
148+
</p>
149+
<p>Account ID: {account?.id ?? 'N/A'}</p>
150+
<p>Organization Name: {organizations?.[0]?.name ?? 'N/A'}</p>
151+
<p>Billing Contact: {account?.billingContact?.email ?? 'N/A'}</p>
152+
<Grid.Column widthXS={Columns.Twelve}>
153+
<Form.ValidationElement
154+
value={startDateInput}
155+
validationFunc={handleStartDateValidation}
156+
label="Contract Start Date (YYYY-MM-DD)"
157+
>
158+
{status => (
159+
<Input
160+
placeholder="YYYY-MM-DD"
161+
onChange={handleInput}
162+
value={startDateInput}
163+
status={status}
164+
/>
165+
)}
166+
</Form.ValidationElement>
167+
</Grid.Column>
168+
<Grid.Column widthXS={Columns.Twelve}>
169+
<Form.Element label="Date Picker">
170+
<Popover
171+
appearance={Appearance.Outline}
172+
position={PopoverPosition.ToTheRight}
173+
triggerRef={triggerRef}
174+
visible={isDatePickerOpen}
175+
showEvent={PopoverInteraction.None}
176+
hideEvent={PopoverInteraction.None}
177+
distanceFromTrigger={8}
178+
testID="timerange-popover"
179+
enableDefaultStyles={false}
180+
contents={() => (
181+
<ClickOutside onClickOutside={onClickOutside}>
182+
<div className="range-picker react-datepicker-ignore-onclickoutside contract-start-date-picker">
183+
<button
184+
className="range-picker--dismiss"
185+
onClick={hideDatePicker}
186+
/>
187+
<div className="range-picker--date-pickers">
188+
<DatePicker
189+
dateTime={getDatePickerDateTime()}
190+
onSelectDate={handleSelectDate}
191+
label="Contract Start Date"
192+
onInvalidInput={noOp}
193+
/>
194+
</div>
195+
</div>
196+
</ClickOutside>
197+
)}
198+
/>
199+
<Button
200+
ref={triggerRef}
201+
color={ComponentColor.Primary}
202+
onClick={toggleDatePicker}
203+
onMouseEnter={suppressOnClickOutside}
204+
onMouseLeave={allowOnClickOutside}
205+
size={ComponentSize.Small}
206+
icon={IconFont.Calendar}
207+
/>
208+
</Form.Element>
209+
</Grid.Column>
210+
</Overlay.Body>
211+
<Overlay.Footer>
212+
<ButtonBase
213+
color={ComponentColor.Primary}
214+
shape={ButtonShape.Default}
215+
onClick={convertAccountToContract}
216+
testID="convert-account-to-contract--confirmation-button"
217+
>
218+
Convert account
219+
</ButtonBase>
220+
</Overlay.Footer>
221+
</Overlay.Container>
222+
</Overlay>
223+
)
224+
}
225+
226+
export default ConvertAccountToContractOverlay

src/operator/account/DeleteAccountOverlay.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,16 @@ const DeleteAccountOverlay: FC = () => {
1515
account,
1616
handleDeleteAccount,
1717
organizations,
18-
setVisible,
19-
visible,
18+
setDeleteOverlayVisible,
19+
deleteOverlayVisible,
2020
} = useContext(AccountContext)
2121

2222
const deleteAccount = () => {
2323
if (account?.deletable) {
2424
try {
2525
handleDeleteAccount()
2626
} catch (e) {
27-
setVisible(false)
27+
setDeleteOverlayVisible(false)
2828
}
2929
}
3030
}
@@ -36,7 +36,7 @@ const DeleteAccountOverlay: FC = () => {
3636
${organizations?.[0]?.name ?? 'N/A'}.`
3737
return (
3838
<Overlay
39-
visible={visible}
39+
visible={deleteOverlayVisible}
4040
renderMaskElement={() => (
4141
<Overlay.Mask gradient={Gradients.DangerDark} style={{opacity: 0.5}} />
4242
)}
@@ -47,7 +47,7 @@ const DeleteAccountOverlay: FC = () => {
4747
<Overlay.Header
4848
title="Delete Account"
4949
style={{color: '#FFFFFF'}}
50-
onDismiss={() => setVisible(!visible)}
50+
onDismiss={() => setDeleteOverlayVisible(!deleteOverlayVisible)}
5151
/>
5252
<Overlay.Body>
5353
<Alert color={ComponentColor.Danger} icon={IconFont.AlertTriangle}>

0 commit comments

Comments
 (0)