From d76a6f05bf958a69c418ec7fd00099f24f312b7b Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Tue, 15 Oct 2024 10:51:11 +0100 Subject: [PATCH] feat: organisation integrations (#4704) --- docs/docs/deployment/index.md | 87 ++-- frontend/common/types/responses.ts | 29 ++ frontend/web/components/App.js | 34 +- frontend/web/components/IntegrationList.js | 435 ---------------- frontend/web/components/IntegrationList.tsx | 484 ++++++++++++++++++ .../web/components/base/forms/Checkbox.tsx | 2 +- .../modals/CreateEditIntegrationModal.js | 341 ------------ .../modals/CreateEditIntegrationModal.tsx | 361 +++++++++++++ .../web/components/pages/IntegrationsPage.js | 74 --- .../web/components/pages/IntegrationsPage.tsx | 84 +++ .../pages/OrganisationIntegrationsPage.tsx | 49 ++ frontend/web/routes.js | 71 +-- 12 files changed, 1126 insertions(+), 925 deletions(-) delete mode 100644 frontend/web/components/IntegrationList.js create mode 100644 frontend/web/components/IntegrationList.tsx delete mode 100644 frontend/web/components/modals/CreateEditIntegrationModal.js create mode 100644 frontend/web/components/modals/CreateEditIntegrationModal.tsx delete mode 100644 frontend/web/components/pages/IntegrationsPage.js create mode 100644 frontend/web/components/pages/IntegrationsPage.tsx create mode 100644 frontend/web/components/pages/OrganisationIntegrationsPage.tsx diff --git a/docs/docs/deployment/index.md b/docs/docs/deployment/index.md index e2642aeca833..bbc46f359ba4 100644 --- a/docs/docs/deployment/index.md +++ b/docs/docs/deployment/index.md @@ -231,7 +231,8 @@ The list of the flags and remote config we're currently using in production is b ], "tags": ["logging"], "title": "Datadog", - "description": "Sends events to Datadog for when flags are created, updated and removed. Logs are tagged with the environment they came from e.g. production." + "description": "Sends events to Datadog for when flags are created, updated and removed. Logs are tagged with the environment they came from e.g. production.", + "project": true }, "dynatrace": { "perEnvironment": true, @@ -254,27 +255,8 @@ The list of the flags and remote config we're currently using in production is b ], "tags": ["logging"], "title": "Dynatrace", - "description": "Sends events to Dynatrace for when flags are created, updated and removed. Logs are tagged with the environment they came from e.g. production." - }, - "grafana": { - "perEnvironment": false, - "image": "/static/images/integrations/grafana.svg", - "docs": "https://docs.flagsmith.com/integrations/apm/grafana", - "fields": [ - { - "key": "base_url", - "label": "Base URL", - "default": "https://grafana.com" - }, - { - "key": "api_key", - "label": "Service account token", - "hidden": true - } - ], - "tags": ["logging"], - "title": "Grafana", - "description": "Receive Flagsmith annotations to your Grafana instance on feature flag and segment changes." + "description": "Sends events to Dynatrace for when flags are created, updated and removed. Logs are tagged with the environment they came from e.g. production.", + "project": true }, "jira": { "perEnvironment": false, @@ -282,9 +264,20 @@ The list of the flags and remote config we're currently using in production is b "docs": "https://docs.flagsmith.com/integrations/project-management/jira", "external": true, "title": "Jira", - "description": "View your Flagsmith Flags inside Jira." + "description": "View your Flagsmith Flags inside Jira.", + "project": true, + "organisation": true + }, + "github": { + "perEnvironment": false, + "image": "https://docs.flagsmith.com/img/integrations/github/github-logo.svg", + "docs": "https://docs.flagsmith.com/integrations/project-management/github", + "external": true, + "title": "GitHub", + "isExternalInstallation": true, + "description": "View your Flagsmith Flags inside your GitHub Issues and Pull Request.", + "project": true }, - "slack": { "perEnvironment": true, "isOauth": true, @@ -292,7 +285,8 @@ The list of the flags and remote config we're currently using in production is b "docs": "https://docs.flagsmith.com/integrations/slack", "tags": ["messaging"], "title": "Slack", - "description": "Sends messages to Slack when flags are created, updated and removed. Logs are tagged with the environment they came from e.g. production." + "description": "Sends messages to Slack when flags are created, updated and removed. Logs are tagged with the environment they came from e.g. production.", + "project": true }, "amplitude": { "perEnvironment": true, @@ -311,7 +305,8 @@ The list of the flags and remote config we're currently using in production is b ], "tags": ["analytics"], "title": "Amplitude", - "description": "Sends data on what flags served to each identity." + "description": "Sends data on what flags served to each identity.", + "project": true }, "new-relic": { "perEnvironment": false, @@ -334,7 +329,8 @@ The list of the flags and remote config we're currently using in production is b ], "tags": ["analytics"], "title": "New Relic", - "description": "Sends events to New Relic for when flags are created, updated and removed." + "description": "Sends events to New Relic for when flags are created, updated and removed.", + "project": true }, "segment": { "perEnvironment": true, @@ -349,7 +345,8 @@ The list of the flags and remote config we're currently using in production is b ], "tags": ["analytics"], "title": "Segment", - "description": "Sends data on what flags served to each identity." + "description": "Sends data on what flags served to each identity.", + "project": true }, "rudderstack": { "perEnvironment": true, @@ -368,7 +365,8 @@ The list of the flags and remote config we're currently using in production is b ], "tags": ["analytics"], "title": "Rudderstack", - "description": "Sends data on what flags served to each identity." + "description": "Sends data on what flags served to each identity.", + "project": true }, "webhook": { "perEnvironment": true, @@ -387,7 +385,8 @@ The list of the flags and remote config we're currently using in production is b ], "tags": ["analytics"], "title": "Webhook", - "description": "Sends data on what flags served to each identity to a Webhook Endpoint you provide." + "description": "Sends data on what flags served to each identity to a Webhook Endpoint you provide.", + "project": true }, "heap": { "perEnvironment": true, @@ -402,7 +401,8 @@ The list of the flags and remote config we're currently using in production is b ], "tags": ["analytics"], "title": "Heap Analytics", - "description": "Sends data on what flags served to each identity." + "description": "Sends data on what flags served to each identity.", + "project": true }, "mixpanel": { "perEnvironment": true, @@ -417,7 +417,30 @@ The list of the flags and remote config we're currently using in production is b ], "tags": ["analytics"], "title": "Mixpanel", - "description": "Sends data on what flags served to each identity." + "description": "Sends data on what flags served to each identity.", + "project": true + }, + "grafana": { + "perEnvironment": false, + "image": "/static/images/integrations/grafana.svg", + "docs": "https://docs.flagsmith.com/integrations/apm/grafana", + "fields": [ + { + "key": "base_url", + "label": "Base URL", + "default": "https://grafana.com" + }, + { + "key": "api_key", + "label": "Service account token", + "hidden": true + } + ], + "tags": ["logging"], + "title": "Grafana", + "description": "Receive Flagsmith annotations to your Grafana instance on feature flag and segment changes.", + "project": true, + "organisation": true } } ``` diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 1ea21e07ec8a..e0db01498636 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -183,6 +183,35 @@ export type Repository = { owner: { login: string } } +export type IntegrationFieldOption = { label: string; value: string } +export type IntegrationField = { + key: string + label: string + default?: string + hidden?: boolean + inputType?: 'text' | 'checkbox' + options?: IntegrationFieldOption[] +} + +export type IntegrationData = { + description: string + docs?: string + external: boolean + image: string + fields: IntegrationField[] | undefined + isExternalInstallation: boolean + perEnvironment: boolean + title?: string + organisation?: string + project?: string + isOauth?: boolean +} + +export type ActiveIntegration = { + id: string + flagsmithEnvironment?: string +} + export type GithubRepository = { id: number github_configuration: number diff --git a/frontend/web/components/App.js b/frontend/web/components/App.js index 2f04d05e527a..9d0d2b35c16c 100644 --- a/frontend/web/components/App.js +++ b/frontend/web/components/App.js @@ -643,17 +643,31 @@ const App = class extends Component { Usage )} - {AccountStore.isAdmin() && ( - } - id='org-settings-link' - to={`/organisation/${ - AccountStore.getOrganisation().id - }/settings`} - > - Organisation Settings - + <> + {Utils.getFlagsmithHasFeature( + 'organisation_integrations', + ) && ( + } + id='integrations-link' + to={`/organisation/${ + AccountStore.getOrganisation().id + }/integrations`} + > + Organisation Integrations + + )} + } + id='org-settings-link' + to={`/organisation/${ + AccountStore.getOrganisation().id + }/settings`} + > + Organisation Settings + + )} ) diff --git a/frontend/web/components/IntegrationList.js b/frontend/web/components/IntegrationList.js deleted file mode 100644 index 0b49b25badd7..000000000000 --- a/frontend/web/components/IntegrationList.js +++ /dev/null @@ -1,435 +0,0 @@ -import React, { Component } from 'react' -import _data from 'common/data/base/_data' -import ProjectStore from 'common/stores/project-store' -import ConfigProvider from 'common/providers/ConfigProvider' -import { getStore } from 'common/store' -import { getGithubIntegration } from 'common/services/useGithubIntegration' - -const CreateEditIntegration = require('./modals/CreateEditIntegrationModal') -const GITHUB_INSTALLATION_SETUP = 'install' - -class Integration extends Component { - state = { - reFetchgithubId: '', - windowInstallationId: '', - } - add = () => { - const isGithubIntegration = - this.props.githubMeta.githubId && - this.props.integration.isExternalInstallation - if (isGithubIntegration) { - this.props.addIntegration( - this.props.integration, - this.props.id, - this.props.githubMeta.installationId, - this.props.githubMeta.githubId, - ) - } else if (this.state.windowInstallationId) { - this.props.addIntegration( - this.props.integration, - this.props.id, - this.state.windowInstallationId, - this.state.reFetchgithubId, - ) - } else { - this.props.addIntegration(this.props.integration, this.props.id) - } - } - - openChildWin = () => { - const childWindow = window.open( - `${Project.githubAppURL}`, - '_blank', - 'height=700%,width=800%,status=yes,toolbar=no,menubar=no,addressbar=no', - ) - - childWindow.localStorage.setItem( - 'githubIntegrationSetupFromFlagsmith', - GITHUB_INSTALLATION_SETUP, - ) - window.addEventListener('message', (event) => { - if ( - event.source === childWindow && - (event.data?.hasOwnProperty('installationId') || installationId) - ) { - this.setState({ windowInstallationId: event.data.installationId }) - localStorage.removeItem('githubIntegrationSetupFromFlagsmith') - childWindow.close() - getGithubIntegration( - getStore(), - { - organisation_id: AccountStore.getOrganisation().id, - }, - { forceRefetch: true }, - ).then((res) => { - this.setState({ - reFetchgithubId: res?.data?.results[0]?.id, - }) - this.add() - }) - } - }) - } - - remove = (integration) => { - this.props.removeIntegration(integration, this.props.id) - } - - edit = (integration) => { - this.props.editIntegration( - this.props.integration, - this.props.id, - integration, - ) - } - - render() { - const { - description, - docs, - external, - image, - isExternalInstallation, - perEnvironment, - } = this.props.integration - const activeIntegrations = this.props.activeIntegrations - const showAdd = !( - !perEnvironment && - activeIntegrations && - activeIntegrations.length - ) - return ( -
- - -
- {description}{' '} - {docs && ( - - )} -
- - {activeIntegrations && - activeIntegrations.map((integration) => ( - - ))} - {showAdd && ( - <> - {external && !isExternalInstallation ? ( - - Add Integration - - ) : external && - isExternalInstallation && - (this.state.windowInstallationId || - this.props.githubMeta.hasIntegrationWithGithub) ? ( - - ) : external && - !this.props.githubMeta.hasIntegrationWithGithub && - isExternalInstallation ? ( - - ) : ( - - )} - - )} - -
- - {activeIntegrations && - activeIntegrations.map((integration) => ( -
this.edit(integration)} - > - - - - - -
- ))} -
- ) - } -} - -class IntegrationList extends Component { - state = { - githubId: 0, - hasIntegrationWithGithub: false, - installationId: '', - } - - static contextTypes = { - router: propTypes.object.isRequired, - } - - componentDidMount() { - this.fetch() - if (Utils.getFlagsmithHasFeature('github_integration')) { - this.fetchGithubIntegration() - } - } - - fetchGithubIntegration = () => { - getGithubIntegration( - getStore(), - { - organisation_id: AccountStore.getOrganisation().id, - }, - { forceRefetch: true }, - ).then((res) => { - this.setState({ - githubId: res?.data?.results[0]?.id, - hasIntegrationWithGithub: !!res?.data?.results?.length, - installationId: res?.data?.results[0]?.installation_id, - }) - }) - } - - fetch = () => { - const integrationList = Utils.getIntegrationData() - this.setState({ isLoading: true }) - Promise.all( - this.props.integrations.map((key) => { - const integration = integrationList[key] - if (integration) { - if (integration.perEnvironment) { - return Promise.all( - ProjectStore.getEnvs().map((env) => - _data - .get( - `${Project.api}environments/${env.api_key}/integrations/${key}/`, - ) - .catch(() => {}), - ), - ).then((res) => { - let allItems = [] - _.each(res, (envIntegrations, index) => { - if (envIntegrations && envIntegrations.length) { - allItems = allItems.concat( - envIntegrations.map((int) => ({ - ...int, - flagsmithEnvironment: - ProjectStore.getEnvs()[index].api_key, - })), - ) - } - }) - return allItems - }) - } - if (key !== 'github') { - return _data - .get( - `${Project.api}projects/${this.props.projectId}/integrations/${key}/`, - ) - .catch(() => {}) - } - } - }), - ).then((res) => { - this.setState({ - activeIntegrations: _.map(res, (item) => - !!item && item.length ? item : [], - ), - isLoading: false, - }) - }) - const params = Utils.fromParam() - if (params && params.configure) { - const integrationList = Utils.getIntegrationData() - - if (integrationList && integrationList[params.configure]) { - setTimeout(() => { - this.addIntegration( - integrationList[params.configure], - params.configure, - ) - this.context.router.history.replace(document.location.pathname) - }, 500) - } - } - } - - removeIntegration = (integration, id) => { - const env = integration.flagsmithEnvironment - ? ProjectStore.getEnvironment(integration.flagsmithEnvironment) - : '' - const name = env && env.name - openConfirm({ - body: ( - - This will remove your integration from the{' '} - {integration.flagsmithEnvironment ? 'environment ' : 'project'} - {name ? {name} : ''}, it will no longer receive data. - Are you sure? - - ), - destructive: true, - onYes: () => { - if (integration.flagsmithEnvironment) { - _data - .delete( - `${Project.api}environments/${integration.flagsmithEnvironment}/integrations/${id}/${integration.id}/`, - ) - .then(this.fetch) - .catch(this.onError) - } else { - _data - .delete( - `${Project.api}projects/${this.props.projectId}/integrations/${id}/${integration.id}/`, - ) - .then(this.fetch) - .catch(this.onError) - } - }, - title: 'Delete integration', - yesText: 'Confirm', - }) - } - - addIntegration = ( - integration, - id, - installationId = undefined, - githubId = undefined, - ) => { - const params = Utils.fromParam() - openModal( - `${integration.title} Integration`, - , - 'side-modal', - ) - } - - editIntegration = (integration, id, data) => { - openModal( - `${integration.title} Integration`, - , - 'p-0', - ) - } - - render() { - const integrationList = Utils.getIntegrationData() - return ( -
-
{ - this.fetchGithubIntegration() - }} - > - {this.props.integrations && - !this.state.isLoading && - this.state.activeIntegrations && - integrationList ? ( - this.props.integrations.map((i, index) => ( - - )) - ) : ( -
- -
- )} -
-
- ) - } -} - -export default ConfigProvider(IntegrationList) diff --git a/frontend/web/components/IntegrationList.tsx b/frontend/web/components/IntegrationList.tsx new file mode 100644 index 000000000000..736a33d62ab6 --- /dev/null +++ b/frontend/web/components/IntegrationList.tsx @@ -0,0 +1,484 @@ +import React, { FC, useState, useEffect } from 'react' +import _data from 'common/data/base/_data' +import ProjectStore from 'common/stores/project-store' +import ConfigProvider from 'common/providers/ConfigProvider' +import { getStore } from 'common/store' +import { getGithubIntegration } from 'common/services/useGithubIntegration' +import AccountStore from 'common/stores/account-store' +import CreateEditIntegration from './modals/CreateEditIntegrationModal' +import Project from 'common/project' +import { + ActiveIntegration, + Environment, + IntegrationData, +} from 'common/types/responses' // Assuming these utilities exist +import map from 'lodash/map' +import Button from './base/forms/Button' +import Utils from 'common/utils/utils' +import { RouterChildContext } from 'react-router' +import each from 'lodash/each' + +const GITHUB_INSTALLATION_SETUP = 'install' + +type IntegrationProps = { + integration: IntegrationData + activeIntegrations: ActiveIntegration[] + id: string + addIntegration: ( + integration: IntegrationData, + id: string, + installationId?: any, + githubId?: any, + ) => void + removeIntegration: (integration: ActiveIntegration, id: string) => void + editIntegration: ( + integration: IntegrationData, + id: string, + data: ActiveIntegration, + ) => void + organisationId?: string + projectId?: string + githubMeta: { + githubId: number + hasIntegrationWithGithub: boolean + installationId: string + } +} + +const Integration: FC = (props) => { + const [reFetchgithubId, setReFetchgithubId] = useState('') + const [windowInstallationId, setWindowInstallationId] = useState('') + + const add = () => { + const isGithubIntegration = + props.githubMeta.githubId && props.integration.isExternalInstallation + if (isGithubIntegration) { + props.addIntegration( + props.integration, + props.id, + props.githubMeta.installationId, + props.githubMeta.githubId, + ) + } else if (windowInstallationId) { + props.addIntegration( + props.integration, + props.id, + windowInstallationId, + reFetchgithubId, + ) + } else { + props.addIntegration(props.integration, props.id) + } + } + + const openChildWin = () => { + const childWindow = window.open( + `${Project.githubAppURL}`, + '_blank', + 'height=700%,width=800%,status=yes,toolbar=no,menubar=no,addressbar=no', + ) + + childWindow?.localStorage.setItem( + 'githubIntegrationSetupFromFlagsmith', + GITHUB_INSTALLATION_SETUP, + ) + window.addEventListener('message', (event) => { + if ( + event.source === childWindow && + (event.data?.hasOwnProperty('installationId') || + event.data.installationId) + ) { + setWindowInstallationId(event.data.installationId) + localStorage.removeItem('githubIntegrationSetupFromFlagsmith') + childWindow?.close() + getGithubIntegration( + getStore(), + { + organisation_id: AccountStore.getOrganisation().id, + }, + { forceRefetch: true }, + ).then((res) => { + setReFetchgithubId(res?.data?.results[0]?.id) + add() + }) + } + }) + } + + const remove = (integration: ActiveIntegration) => { + props.removeIntegration(integration, props.id) + } + + const edit = (integration: ActiveIntegration) => { + props.editIntegration(props.integration, props.id, integration) + } + + const { + description, + docs, + external, + image, + isExternalInstallation, + perEnvironment, + } = props.integration + const activeIntegrations = props.activeIntegrations + const showAdd = !( + !perEnvironment && + activeIntegrations && + activeIntegrations.length + ) + + return ( +
+ Integration + +
+ {description}{' '} + {docs && ( + + )} +
+ + {activeIntegrations && + activeIntegrations.map((integration) => ( + + ))} + {showAdd && ( + <> + {external && !isExternalInstallation ? ( + + Add Integration + + ) : external && + isExternalInstallation && + (windowInstallationId || + props.githubMeta.hasIntegrationWithGithub) ? ( + + ) : external && + !props.githubMeta.hasIntegrationWithGithub && + isExternalInstallation ? ( + + ) : ( + + )} + + )} + +
+ + {activeIntegrations && + activeIntegrations.map((integration) => ( +
edit(integration)} + > + + + + + +
+ ))} +
+ ) +} + +interface IntegrationListProps { + router: RouterChildContext['router'] + match: { + params: { + environmentId: string + projectId: string + id: string + identity: string + } + } + integrations: string[] + projectId?: string + organisationId: string +} +const IntegrationList: FC = (props) => { + const [githubId, setGithubId] = useState(0) + const [hasIntegrationWithGithub, setHasIntegrationWithGithub] = + useState(false) + const [installationId, setInstallationId] = useState('') + const [isLoading, setIsLoading] = useState(true) + const [activeIntegrations, setActiveIntegrations] = useState([]) + const history = props.router.history + + const organisationId = props.organisationId + + useEffect(() => { + fetch() + if (Utils.getFlagsmithHasFeature('github_integration')) { + fetchGithubIntegration() + } + }, []) + + const fetchGithubIntegration = () => { + getGithubIntegration( + getStore(), + { + organisation_id: AccountStore.getOrganisation().id, + }, + { forceRefetch: true }, + ).then((res) => { + setGithubId(res?.data?.results[0]?.id) + setHasIntegrationWithGithub(!!res?.data?.results?.length) + setInstallationId(res?.data?.results[0]?.installation_id) + }) + } + + const fetch = () => { + const integrationList = Utils.getIntegrationData() + setIsLoading(true) + Promise.all( + props.integrations.map((key) => { + const integration = integrationList[key] + if (integration) { + if (props.organisationId) { + return _data + .get( + `${Project.api}organisations/${organisationId}/integrations/${key}/`, + ) + .catch(() => {}) + } else if (integration.perEnvironment) { + return Promise.all( + ((ProjectStore.getEnvs() as any) || []).map((env: Environment) => + _data + .get( + `${Project.api}environments/${env.api_key}/integrations/${key}/`, + ) + .catch(() => {}), + ), + ).then((res) => { + let allItems: any[] = [] + each(res, (envIntegrations, index) => { + if (envIntegrations && envIntegrations.length) { + allItems = allItems.concat( + envIntegrations.map((integration: any) => ({ + ...integration, + flagsmithEnvironment: ( + ProjectStore.getEnvs()?.[index] as Environment | null + )?.api_key, + })), + ) + } + }) + return allItems + }) + } + if (key !== 'github') { + return _data + .get( + `${Project.api}projects/${props.projectId}/integrations/${key}/`, + ) + .catch(() => {}) + } + } + }), + ).then((res) => { + setActiveIntegrations( + map(res, (item) => (!!item && item.length ? item : [])), + ) + setIsLoading(false) + }) + const params = Utils.fromParam() + if (params && params.configure) { + const integrationList = Utils.getIntegrationData() + + if (integrationList && integrationList[params.configure]) { + setTimeout(() => { + addIntegration(integrationList[params.configure], params.configure) + history.replace(document.location.pathname) + }, 500) + } + } + } + + const removeIntegration = (integration: any, id: string) => { + const env = integration.flagsmithEnvironment + ? (ProjectStore.getEnvironment( + integration.flagsmithEnvironment, + ) as Environment | null) + : null + const name = env?.name + openConfirm({ + body: ( + + This will remove your integration from the{' '} + {integration.flagsmithEnvironment ? 'environment ' : 'project'} + {name ? {name} : ''}, it will no longer receive data. + Are you sure? + + ), + destructive: true, + onYes: () => { + if (organisationId) { + _data + .delete( + `${Project.api}organisations/${organisationId}/integrations/${id}/${integration.id}/`, + ) + .then(fetch) + } else if (integration.flagsmithEnvironment) { + _data + .delete( + `${Project.api}environments/${integration.flagsmithEnvironment}/integrations/${id}/${integration.id}/`, + ) + .then(fetch) + } else { + _data + .delete( + `${Project.api}projects/${props.projectId}/integrations/${id}/${integration.id}/`, + ) + .then(fetch) + } + }, + title: 'Delete integration', + yesText: 'Confirm', + }) + } + + const addIntegration = ( + integration: any, + id: string, + installationId: any = undefined, + githubId: any = undefined, + ) => { + const params = Utils.fromParam() + openModal( + `${integration.title} Integration`, + , + 'side-modal', + ) + } + + const editIntegration = (integration: any, id: string, data: any) => { + openModal( + `${integration.title} Integration`, + , + 'p-0', + ) + } + + return ( +
+
{ + fetchGithubIntegration() + }} + > + {props.integrations && + !isLoading && + activeIntegrations && + Utils.getIntegrationData() ? ( + props.integrations.map((i, index) => ( + + )) + ) : ( +
+ +
+ )} +
+
+ ) +} + +export default ConfigProvider(IntegrationList) diff --git a/frontend/web/components/base/forms/Checkbox.tsx b/frontend/web/components/base/forms/Checkbox.tsx index 02132b06cb86..66e59b081d68 100644 --- a/frontend/web/components/base/forms/Checkbox.tsx +++ b/frontend/web/components/base/forms/Checkbox.tsx @@ -23,7 +23,7 @@ const Checkbox: React.FC = ({ return ( <> - +