Skip to content

Commit

Permalink
feat: display warning and prevent creation on limit (#2526)
Browse files Browse the repository at this point in the history
Co-authored-by: Matthew Elwell <matthew.elwell@flagsmith.com>
Co-authored-by: kyle-ssg <kyle@solidstategroup.com>
  • Loading branch information
3 people committed Sep 18, 2023
1 parent fce2e3a commit 000be2b
Show file tree
Hide file tree
Showing 31 changed files with 528 additions and 100 deletions.
3 changes: 2 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
*.json
*.handlebars
*.css
.bablerc
.bablerc
**/CHANGELOG.md
2 changes: 1 addition & 1 deletion frontend/common/dispatcher/app-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,13 @@ const AppActions = Object.assign({}, require('./base/_app-actions'), {
name,
})
},

createProject(name) {
Dispatcher.handleViewAction({
actionType: Actions.CREATE_PROJECT,
name,
})
},

deleteChangeRequest(id, cb) {
Dispatcher.handleViewAction({
actionType: Actions.DELETE_CHANGE_REQUEST,
Expand Down
6 changes: 5 additions & 1 deletion frontend/common/providers/FeatureListProvider.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react'
import FeatureListStore from 'common/stores/feature-list-store'
import ProjectStore from 'common/stores/project-store'

const FeatureListProvider = class extends React.Component {
static displayName = 'FeatureListProvider'
Expand All @@ -11,8 +12,9 @@ const FeatureListProvider = class extends React.Component {
isLoading: FeatureListStore.isLoading,
isSaving: FeatureListStore.isSaving,
lastSaved: FeatureListStore.getLastSaved(),
maxFeaturesAllowed: ProjectStore.getMaxFeaturesAllowed(),
projectFlags: FeatureListStore.getProjectFlags(),
usageData: FeatureListStore.getFeatureUsage(),
totalFeatures: ProjectStore.getTotalFeatures(),
}
ES6Component(this)
this.listenTo(FeatureListStore, 'change', () => {
Expand All @@ -22,7 +24,9 @@ const FeatureListProvider = class extends React.Component {
isLoading: FeatureListStore.isLoading,
isSaving: FeatureListStore.isSaving,
lastSaved: FeatureListStore.getLastSaved(),
maxFeaturesAllowed: ProjectStore.getMaxFeaturesAllowed(),
projectFlags: FeatureListStore.getProjectFlags(),
totalFeatures: ProjectStore.getTotalFeatures(),
usageData: FeatureListStore.getFeatureUsage(),
})
})
Expand Down
21 changes: 21 additions & 0 deletions frontend/common/services/useEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ export const environmentService = service
.enhanceEndpoints({ addTagTypes: ['Environment'] })
.injectEndpoints({
endpoints: (builder) => ({
getEnvironment: builder.query<Res['environment'], Req['getEnvironment']>({
providesTags: (res) => [{ id: res?.id, type: 'Environment' }],
query: (query: Req['getEnvironment']) => ({
url: `environments/${query.id}/`,
}),
}),
getEnvironments: builder.query<
Res['environments'],
Req['getEnvironments']
Expand Down Expand Up @@ -33,9 +39,24 @@ export async function getEnvironments(
store.dispatch(environmentService.util.getRunningQueriesThunk()),
)
}
export async function getEnvironment(
store: any,
data: Req['getEnvironment'],
options?: Parameters<
typeof environmentService.endpoints.getEnvironment.initiate
>[1],
) {
store.dispatch(
environmentService.endpoints.getEnvironment.initiate(data, options),
)
return Promise.all(
store.dispatch(environmentService.util.getRunningQueriesThunk()),
)
}
// END OF FUNCTION_EXPORTS

export const {
useGetEnvironmentQuery,
useGetEnvironmentsQuery,
// END OF EXPORTS
} = environmentService
Expand Down
49 changes: 49 additions & 0 deletions frontend/common/services/useSubscriptionMetadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Res } from 'common/types/responses'
import { Req } from 'common/types/requests'
import { service } from 'common/service'

export const getSubscriptionMetadataService = service
.enhanceEndpoints({ addTagTypes: ['GetSubscriptionMetadata'] })
.injectEndpoints({
endpoints: (builder) => ({
getSubscriptionMetadata: builder.query<
Res['getSubscriptionMetadata'],
Req['getSubscriptionMetadata']
>({
providesTags: (res) => [
{ id: res?.id, type: 'GetSubscriptionMetadata' },
],
query: (query: Req['getSubscriptionMetadata']) => ({
url: `organisations/${query.id}/get-subscription-metadata/`,
}),
}),
// END OF ENDPOINTS
}),
})

export async function getSubscriptionMetadata(
store: any,
data: Req['getSubscriptionMetadata'],
options?: Parameters<
typeof getSubscriptionMetadataService.endpoints.getSubscriptionMetadata.initiate
>[1],
) {
return store.dispatch(
getSubscriptionMetadataService.endpoints.getSubscriptionMetadata.initiate(
data,
options,
),
)
}
// END OF FUNCTION_EXPORTS

export const {
useGetSubscriptionMetadataQuery,
// END OF EXPORTS
} = getSubscriptionMetadataService

/* Usage examples:
const { data, isLoading } = useGetSubscriptionMetadataQuery({ id: 2 }, {}) //get hook
const [getSubscriptionMetadata, { isLoading, data, isSuccess }] = useGetSubscriptionMetadataMutation() //create hook
getSubscriptionMetadataService.endpoints.getSubscriptionMetadata.select({id: 2})(store.getState()) //access data from any function
*/
31 changes: 31 additions & 0 deletions frontend/common/stores/project-store.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getIsWidget } from 'components/pages/WidgetPage'

import Constants from 'common/constants'
import Utils from 'common/utils/utils'

const Dispatcher = require('../dispatcher/dispatcher')
const BaseStore = require('./base/_store')
Expand Down Expand Up @@ -92,6 +93,12 @@ const controller = {
data.get(`${Project.api}environments/?project=${id}`).catch(() => []),
])
.then(([project, environments]) => {
project.max_segments_allowed = project.max_segments_allowed
project.max_features_allowed = project.max_features_allowed
project.max_segment_overrides_allowed =
project.max_segment_overrides_allowed
project.total_features = project.total_features || 0
project.total_segments = project.total_segments || 0
store.model = Object.assign(project, {
environments: _.sortBy(environments.results, 'name'),
})
Expand All @@ -118,6 +125,12 @@ const controller = {
data.get(`${Project.api}environments/?project=${id}`).catch(() => []),
])
.then(([project, environments]) => {
project.max_segments_allowed = project.max_segments_allowed
project.max_features_allowed = project.max_features_allowed
project.max_segment_overrides_allowed =
project.max_segment_overrides_allowed
project.total_features = project.total_features || 0
project.total_segments = project.total_segments || 0
store.model = Object.assign(project, {
environments: _.sortBy(environments.results, 'name'),
})
Expand Down Expand Up @@ -162,6 +175,24 @@ const store = Object.assign({}, BaseStore, {
})
},
getEnvs: () => store.model && store.model.environments,
getMaxFeaturesAllowed: () => {
return store.model && store.model.max_features_allowed
},
getMaxSegmentOverridesAllowed: () => {
return store.model && store.model.max_segment_overrides_allowed
},
getMaxSegmentsAllowed: () => {
return store.model && store.model.max_segments_allowed
},
getTotalFeatures: () => {
return store.model && store.model.total_features
},
getTotalSegmentOverrides: () => {
return store.model && store.model.environment.total_segment_overrides
},
getTotalSegments: () => {
return store.model && store.model.total_segments
},
id: 'project',
model: null,
})
Expand Down
3 changes: 3 additions & 0 deletions frontend/common/types/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,5 +105,8 @@ export type Req = {
user: string
}
getProjectFlags: { project: string }
getGetSubscriptionMetadata: { id: string }
getEnvironment: { id: string }
getSubscriptionMetadata: { id: string }
// END OF TYPES
}
8 changes: 8 additions & 0 deletions frontend/common/types/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export type Environment = {
minimum_change_request_approvals?: number
allow_client_traits: boolean
hide_sensitive_data: boolean
total_segment_overrides?: number
}
export type Project = {
id: number
Expand All @@ -62,6 +63,11 @@ export type Project = {
use_edge_identities: boolean
prevent_flag_defaults: boolean
enable_realtime_updates: boolean
max_segments_allowed?: number | null
max_features_allowed?: number | null
max_segment_overrides_allowed?: number | null
total_features?: number
total_segments?: number
environments: Environment[]
}

Expand Down Expand Up @@ -336,5 +342,7 @@ export type Res = {

projectFlags: PagedResponse<ProjectFlag>
identityFeatureStates: IdentityFeatureState[]
getSubscriptionMetadata: { id: string }
environment: Environment
// END OF TYPES
}
35 changes: 26 additions & 9 deletions frontend/common/utils/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
import flagsmith from 'flagsmith'
import { ReactNode } from 'react'
import _ from 'lodash'
import ErrorMessage from 'components/ErrorMessage'
import WarningMessage from 'components/WarningMessage'
import Constants from 'common/constants'

const semver = require('semver')
Expand Down Expand Up @@ -54,25 +56,40 @@ const Utils = Object.assign({}, require('./base/_utils'), {
return 100 - total
},

calculaterRemainingCallsPercentage(value, total) {
const minRemainingPercentage = 30
calculateRemainingLimitsPercentage(
total: number | undefined,
max: number | undefined,
threshold = 90,
) {
if (total === 0) {
return 0
}

const percentage = (value / total) * 100
const remainingPercentage = 100 - percentage

if (remainingPercentage <= minRemainingPercentage) {
return true
const percentage = (total / max) * 100
if (percentage >= threshold) {
return {
percentage: Math.floor(percentage),
}
}
return false
return 0
},

changeRequestsEnabled(value: number | null | undefined) {
return typeof value === 'number'
},

displayLimitAlert(type: string, percentage: number | undefined) {
const envOrProject =
type === 'segment overrides' ? 'environment' : 'project'
return percentage >= 100 ? (
<ErrorMessage
error={`Your ${envOrProject} reached the limit of ${type}, please contact support to discuss increasing this limit.`}
/>
) : percentage ? (
<WarningMessage
warningMessage={`Your ${envOrProject} is using ${percentage}% of the total allowance of ${type}.`}
/>
) : null
},
escapeHtml(html: string) {
const text = document.createTextNode(html)
const p = document.createElement('p')
Expand Down
10 changes: 7 additions & 3 deletions frontend/web/components/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ import ConfigProvider from 'common/providers/ConfigProvider'
import Permission from 'common/providers/Permission'
import { getOrganisationUsage } from 'common/services/useOrganisationUsage'
import Button from './base/forms/Button'
import Icon from 'components/Icon'
import Icon from './Icon'
import AccountStore from 'common/stores/account-store'
import InfoMessage from './InfoMessage'
import OrganisationLimit from './OrganisationLimit'

const App = class extends Component {
static propTypes = {
Expand All @@ -40,7 +41,6 @@ const App = class extends Component {
lastProjectId: '',
pin: '',
showAnnouncement: true,
totalApiCalls: 0,
}

constructor(props, context) {
Expand Down Expand Up @@ -75,7 +75,6 @@ const App = class extends Component {
}).then((res) => {
this.setState({
activeOrganisation: AccountStore.getOrganisation().id,
totalApiCalls: res?.data?.totals.total,
})
})
}
Expand Down Expand Up @@ -484,6 +483,11 @@ const App = class extends Component {
</div>
) : (
<Fragment>
{user && (
<OrganisationLimit
id={AccountStore.getOrganisation()?.id}
/>
)}
{user &&
showBanner &&
Utils.getFlagsmithHasFeature('announcement') &&
Expand Down
23 changes: 22 additions & 1 deletion frontend/web/components/ErrorMessage.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
// import propTypes from 'prop-types';
import React, { PureComponent } from 'react'
import Icon from './Icon'
import PaymentModal from './modals/Payment'

export default class ErrorMessage extends PureComponent {
static displayName = 'ErrorMessage'

render() {
const errorMessageClassName = `alert alert-danger ${
this.props.errorMessageClass || 'flex-1 align-items-center'
}`
return this.props.error ? (
<div className='alert alert-danger flex-1 align-items-center'>
<div
className={errorMessageClassName}
style={{ display: this.props.errorMessageClass ? 'initial' : '' }}
>
<span className='icon-alert'>
<Icon name='close-circle' />
</span>
Expand All @@ -16,6 +23,20 @@ export default class ErrorMessage extends PureComponent {
.map((v) => `${v}: ${this.props.error[v]}`)
.join('\n')
: this.props.error}
{this.props.enabledButton && (
<Button
className='btn ml-3'
onClick={() => {
openModal(
'Payment plans',
<PaymentModal viewOnly={false} />,
'modal-lg',
)
}}
>
Upgrade plan
</Button>
)}
</div>
) : null
}
Expand Down
1 change: 1 addition & 0 deletions frontend/web/components/FlagSelect.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class FlagSelect extends Component {
? options.find((v) => v.value === this.props.value)
: null
}
isDisabled={this.props.disabled}
onInputChange={this.search}
placeholder={this.props.placeholder}
onChange={(v) => this.props.onChange(v.value, v.flag)}
Expand Down

3 comments on commit 000be2b

@vercel
Copy link

@vercel vercel bot commented on 000be2b Sep 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vercel
Copy link

@vercel vercel bot commented on 000be2b Sep 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vercel
Copy link

@vercel vercel bot commented on 000be2b Sep 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

docs – ./docs

docs-flagsmith.vercel.app
docs-git-main-flagsmith.vercel.app
docs.bullet-train.io
docs.flagsmith.com

Please sign in to comment.