diff --git a/api/.pipeline/config.js b/api/.pipeline/config.js index 05d8dc2980..f52e3223a9 100644 --- a/api/.pipeline/config.js +++ b/api/.pipeline/config.js @@ -84,13 +84,13 @@ const phases = { backboneArtifactIntakePath: '/api/artifact/intake', biohubTaxonPath: '/api/taxonomy/taxon', biohubTaxonTsnPath: '/api/taxonomy/taxon/tsn', - backboneIntakeEnabled: true, bctwApiHost: 'https://moe-bctw-api-dev.apps.silver.devops.gov.bc.ca', critterbaseApiHost: 'https://moe-critterbase-api-dev.apps.silver.devops.gov.bc.ca/api', nodeEnv: 'development', s3KeyPrefix: (isStaticDeployment && 'sims') || `local/${deployChangeId}/sims`, tz: config.timezone.api, sso: config.sso.dev, + featureFlags: '', logLevel: 'info', apiResponseValidationEnabled: true, databaseResponseValidationEnabled: true, @@ -120,7 +120,6 @@ const phases = { backboneArtifactIntakePath: '/api/artifact/intake', biohubTaxonPath: '/api/taxonomy/taxon', biohubTaxonTsnPath: '/api/taxonomy/taxon/tsn', - backboneIntakeEnabled: true, bctwApiHost: 'https://moe-bctw-api-test.apps.silver.devops.gov.bc.ca', critterbaseApiHost: 'https://moe-critterbase-api-test.apps.silver.devops.gov.bc.ca/api', nodeEnv: 'production', @@ -128,6 +127,7 @@ const phases = { tz: config.timezone.api, sso: config.sso.test, logLevel: 'info', + featureFlags: '', apiResponseValidationEnabled: true, databaseResponseValidationEnabled: true, nodeOptions: '--max_old_space_size=3000', // 75% of memoryLimit (bytes) @@ -156,13 +156,13 @@ const phases = { backboneArtifactIntakePath: '/api/artifact/intake', biohubTaxonPath: '/api/taxonomy/taxon', biohubTaxonTsnPath: '/api/taxonomy/taxon/tsn', - backboneIntakeEnabled: false, bctwApiHost: 'https://moe-bctw-api-prod.apps.silver.devops.gov.bc.ca', critterbaseApiHost: 'https://moe-critterbase-api-prod.apps.silver.devops.gov.bc.ca/api', nodeEnv: 'production', s3KeyPrefix: 'sims', tz: config.timezone.api, sso: config.sso.prod, + featureFlags: 'API_FF_SUBMIT_BIOHUB', logLevel: 'warn', apiResponseValidationEnabled: false, databaseResponseValidationEnabled: false, diff --git a/api/.pipeline/lib/api.deploy.js b/api/.pipeline/lib/api.deploy.js index 6cdf34c7e5..220710a02f 100644 --- a/api/.pipeline/lib/api.deploy.js +++ b/api/.pipeline/lib/api.deploy.js @@ -38,7 +38,6 @@ const apiDeploy = async (settings) => { BACKBONE_INTERNAL_API_HOST: phases[phase].backboneInternalApiHost, BACKBONE_INTAKE_PATH: phases[phase].backboneIntakePath, BACKBONE_ARTIFACT_INTAKE_PATH: phases[phase].backboneArtifactIntakePath, - BACKBONE_INTAKE_ENABLED: phases[phase].backboneIntakeEnabled, BIOHUB_TAXON_PATH: phases[phase].biohubTaxonPath, BIOHUB_TAXON_TSN_PATH: phases[phase].biohubTaxonTsnPath, // BCTW / Critterbase @@ -68,6 +67,8 @@ const apiDeploy = async (settings) => { LOG_LEVEL: phases[phase].logLevel, API_RESPONSE_VALIDATION_ENABLED: phases[phase].apiResponseValidationEnabled, DATABASE_RESPONSE_VALIDATION_ENABLED: phases[phase].databaseResponseValidationEnabled, + // Feature Flags + FEATURE_FLAGS: phases[phase].featureFlags, // Openshift Resources CPU_REQUEST: phases[phase].cpuRequest, CPU_LIMIT: phases[phase].cpuLimit, diff --git a/api/.pipeline/templates/api.dc.yaml b/api/.pipeline/templates/api.dc.yaml index dadc130ce9..dc2518910a 100644 --- a/api/.pipeline/templates/api.dc.yaml +++ b/api/.pipeline/templates/api.dc.yaml @@ -49,9 +49,6 @@ parameters: - name: BACKBONE_ARTIFACT_INTAKE_PATH required: true description: The publishing intake endpoint path for BioHub Platform artifact submissions. Example "/api/path/to/artifact/intake". - - name: BACKBONE_INTAKE_ENABLED - required: true - description: Controls whether or not SIMS will submit DwCA datasets to the BioHub Platform Backbone. Set to "true" to enable it, will be disabled by default otherwise. - name: BIOHUB_TAXON_TSN_PATH required: true description: API path for BioHub Platform Backbone taxon TSN endpoint. Example "/api/path/to/taxon/tsn". @@ -125,6 +122,10 @@ parameters: value: 'false' - name: DATABASE_RESPONSE_VALIDATION_ENABLED value: 'false' + # Feature Flags + - name: FEATURE_FLAGS + description: Used to identify features that should be temporarily disabled/hidden. Must be a comma delimited list of keywords. + value: '' # GCNotify - name: GCNOTIFY_API_SECRET description: Secret for gcnotify api key @@ -231,8 +232,6 @@ objects: value: ${BACKBONE_INTAKE_PATH} - name: BACKBONE_ARTIFACT_INTAKE_PATH value: ${BACKBONE_ARTIFACT_INTAKE_PATH} - - name: BACKBONE_INTAKE_ENABLED - value: ${BACKBONE_INTAKE_ENABLED} - name: BIOHUB_TAXON_TSN_PATH value: ${BIOHUB_TAXON_TSN_PATH} - name: BIOHUB_TAXON_PATH @@ -344,6 +343,8 @@ objects: value: ${GCNOTIFY_EMAIL_URL} - name: GCNOTIFY_SMS_URL value: ${GCNOTIFY_SMS_URL} + - name: FEATURE_FLAGS + value: ${FEATURE_FLAGS} image: ' ' imagePullPolicy: Always ports: diff --git a/api/src/services/platform-service.test.ts b/api/src/services/platform-service.test.ts index 12171fe315..129b028855 100644 --- a/api/src/services/platform-service.test.ts +++ b/api/src/services/platform-service.test.ts @@ -4,6 +4,7 @@ import { describe } from 'mocha'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { ObservationRecord } from '../repositories/observation-repository/observation-repository'; +import * as featureFlagUtils from '../utils/feature-flag-utils'; import { getMockDBConnection } from '../__mocks__/db'; import { AttachmentService } from './attachment-service'; import { HistoryPublishService } from './history-publish-service'; @@ -24,8 +25,8 @@ describe('PlatformService', () => { sinon.restore(); }); - it('throws an error if BioHub intake is not enabled', async () => { - process.env.BACKBONE_INTAKE_ENABLED = 'false'; + it('throws an error if publishing to BioHub is not currently enabled.', async () => { + sinon.stub(featureFlagUtils, 'isFeatureFlagPresent').returns(true); const mockDBConnection = getMockDBConnection(); const platformService = new PlatformService(mockDBConnection); @@ -34,12 +35,11 @@ describe('PlatformService', () => { await platformService.submitSurveyToBioHub(1, { submissionComment: 'test' }); expect.fail(); } catch (error) { - expect((error as Error).message).to.equal('BioHub intake is not enabled'); + expect((error as Error).message).to.equal('Publishing to BioHub is not currently enabled.'); } }); it('throws error when axios request fails', async () => { - process.env.BACKBONE_INTAKE_ENABLED = 'true'; process.env.BACKBONE_INTERNAL_API_HOST = 'http://backbone-host.dev/'; const mockDBConnection = getMockDBConnection(); @@ -69,7 +69,6 @@ describe('PlatformService', () => { }); it('should submit survey to BioHub successfully', async () => { - process.env.BACKBONE_INTAKE_ENABLED = 'true'; process.env.BACKBONE_INTERNAL_API_HOST = 'http://backbone-host.dev/'; const mockDBConnection = getMockDBConnection(); diff --git a/api/src/services/platform-service.ts b/api/src/services/platform-service.ts index 572aa65e74..5243bb7fe6 100644 --- a/api/src/services/platform-service.ts +++ b/api/src/services/platform-service.ts @@ -8,6 +8,7 @@ import { IDBConnection } from '../database/db'; import { ApiError, ApiErrorType, ApiGeneralError } from '../errors/api-error'; import { PostSurveySubmissionToBioHubObject } from '../models/biohub-create'; import { ISurveyAttachment, ISurveyReportAttachment } from '../repositories/attachment-repository'; +import { isFeatureFlagPresent } from '../utils/feature-flag-utils'; import { getFileFromS3 } from '../utils/file-utils'; import { getLogger } from '../utils/logger'; import { AttachmentService } from './attachment-service'; @@ -56,7 +57,6 @@ export interface ITaxonomy { kingdom: string; } -const getBackboneIntakeEnabled = () => process.env.BACKBONE_INTAKE_ENABLED === 'true' || false; const getBackboneInternalApiHost = () => process.env.BACKBONE_INTERNAL_API_HOST || ''; const getBackboneArtifactIntakePath = () => process.env.BACKBONE_ARTIFACT_INTAKE_PATH || ''; const getBackboneSurveyIntakePath = () => process.env.BACKBONE_INTAKE_PATH || ''; @@ -126,8 +126,8 @@ export class PlatformService extends DBService { ): Promise<{ submission_uuid: string }> { defaultLog.debug({ label: 'submitSurveyToBioHub', message: 'params', surveyId }); - if (!getBackboneIntakeEnabled()) { - throw new ApiGeneralError('BioHub intake is not enabled'); + if (isFeatureFlagPresent(['API_FF_SUBMIT_BIOHUB'])) { + throw new ApiGeneralError('Publishing to BioHub is not currently enabled.'); } const keycloakService = new KeycloakService(); diff --git a/api/src/utils/feature-flag-utils.test.ts b/api/src/utils/feature-flag-utils.test.ts new file mode 100644 index 0000000000..97ff69009a --- /dev/null +++ b/api/src/utils/feature-flag-utils.test.ts @@ -0,0 +1,99 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { getFeatureFlags, getFeatureFlagsString, isFeatureFlagPresent } from './feature-flag-utils'; + +describe('getFeatureFlagsString', () => { + describe('returns the env var `FEATURE_FLAGS`', () => { + const env = Object.assign({}, process.env); + + afterEach(() => { + sinon.restore(); + process.env = env; + }); + + it('when the FEATURE_FLAGS env var is undefined', () => { + process.env.FEATURE_FLAGS = undefined; + + expect(getFeatureFlagsString()).to.equal(undefined); + }); + + it('when the FEATURE_FLAGS env var is defined', () => { + process.env.FEATURE_FLAGS = 'flag1,flag2,flag3'; + + expect(getFeatureFlagsString()).to.equal('flag1,flag2,flag3'); + }); + }); +}); + +describe('getFeatureFlags', () => { + describe('returns an array of flags', () => { + const env = Object.assign({}, process.env); + + afterEach(() => { + sinon.restore(); + process.env = env; + }); + + it('when the FEATURE_FLAGS env var is undefined', () => { + process.env.FEATURE_FLAGS = undefined; + + expect(getFeatureFlags()).to.eql([]); + }); + + it('when the FEATURE_FLAGS env var is defined', () => { + process.env.FEATURE_FLAGS = 'flag1,flag2,flag3'; + + expect(getFeatureFlags()).to.eql(['flag1', 'flag2', 'flag3']); + }); + }); +}); + +describe('isFeatureFlagPresent', () => { + describe('returns true', () => { + const env = Object.assign({}, process.env); + + afterEach(() => { + sinon.restore(); + process.env = env; + }); + + it('when one flag matches', () => { + process.env.FEATURE_FLAGS = 'flag1,flag2,flag3'; + + expect(isFeatureFlagPresent(['flag2'])).to.equal(true); + }); + + it('when two flags match', () => { + process.env.FEATURE_FLAGS = 'flag1,flag2,flag3'; + + expect(isFeatureFlagPresent(['flag2', 'flag1'])).to.equal(true); + }); + + it('when some, but not all, flags match', () => { + process.env.FEATURE_FLAGS = 'flag1,flag2,flag3'; + + expect(isFeatureFlagPresent(['flag4', 'flag3'])).to.equal(true); + }); + }); + + describe('returns false', () => { + const env = Object.assign({}, process.env); + + afterEach(() => { + sinon.restore(); + process.env = env; + }); + + it('when no flags match', () => { + process.env.FEATURE_FLAGS = 'flag1,flag2,flag3'; + + expect(isFeatureFlagPresent(['flag4'])).to.equal(false); + }); + + it('when no flags specified', () => { + process.env.FEATURE_FLAGS = 'flag1,flag2,flag3'; + + expect(isFeatureFlagPresent([])).to.equal(false); + }); + }); +}); diff --git a/api/src/utils/feature-flag-utils.ts b/api/src/utils/feature-flag-utils.ts new file mode 100644 index 0000000000..5559f5ebb8 --- /dev/null +++ b/api/src/utils/feature-flag-utils.ts @@ -0,0 +1,34 @@ +/** + * Returns the raw feature flag string from the environment variable `REACT_APP_FEATURE_FLAGS`. + * + * @return {*} {(string | undefined)} + */ +export const getFeatureFlagsString = (): string | undefined => { + return process.env.FEATURE_FLAGS; +}; + +/** + * Returns a parsed array of feature flag strings from the environment variable `REACT_APP_FEATURE_FLAGS`. + * + * @return {*} {string[]} + */ +export const getFeatureFlags = (): string[] => { + const featureFlagsString = getFeatureFlagsString(); + + if (!featureFlagsString) { + return []; + } + + return featureFlagsString.split(','); +}; + +/** + * Returns `true` if at least one of the provided `featureFlags` is present in the environment variable + * `REACT_APP_FEATURE_FLAGS`. + * + * @param {string[]} featureFlags + * @return {*} {boolean} + */ +export const isFeatureFlagPresent = (featureFlags: string[]): boolean => { + return getFeatureFlags().some((flag) => featureFlags.includes(flag)); +}; diff --git a/app/.pipeline/config.js b/app/.pipeline/config.js index 26853318e4..dc8b453bc6 100644 --- a/app/.pipeline/config.js +++ b/app/.pipeline/config.js @@ -83,13 +83,13 @@ const phases = { maxUploadFileSize, nodeEnv: 'development', sso: config.sso.dev, + featureFlags: '', cpuRequest: '50m', cpuLimit: '300m', memoryRequest: '100Mi', memoryLimit: '500Mi', replicas: '1', replicasMax: '1', - biohubFeatureFlag: 'true', backbonePublicApiHost: 'https://api-dev-biohub-platform.apps.silver.devops.gov.bc.ca', biohubTaxonPath: '/api/taxonomy/taxon', biohubTaxonTsnPath: '/api/taxonomy/taxon/tsn' @@ -110,13 +110,13 @@ const phases = { maxUploadFileSize, nodeEnv: 'production', sso: config.sso.test, + featureFlags: '', cpuRequest: '50m', cpuLimit: '500m', memoryRequest: '100Mi', memoryLimit: '500Mi', replicas: '2', replicasMax: '2', - biohubFeatureFlag: 'true', backbonePublicApiHost: 'https://api-test-biohub-platform.apps.silver.devops.gov.bc.ca', biohubTaxonPath: '/api/taxonomy/taxon', biohubTaxonTsnPath: '/api/taxonomy/taxon/tsn' @@ -137,13 +137,13 @@ const phases = { maxUploadFileSize, nodeEnv: 'production', sso: config.sso.prod, + featureFlags: 'APP_FF_SUBMIT_BIOHUB', cpuRequest: '50m', cpuLimit: '1000m', memoryRequest: '100Mi', memoryLimit: '1Gi', replicas: '2', replicasMax: '2', - biohubFeatureFlag: 'false', backbonePublicApiHost: 'https://api-biohub-platform.apps.silver.devops.gov.bc.ca', biohubTaxonPath: '/api/taxonomy/taxon', biohubTaxonTsnPath: '/api/taxonomy/taxon/tsn' diff --git a/app/.pipeline/lib/app.deploy.js b/app/.pipeline/lib/app.deploy.js index 3a1d7f873a..8f8fe171e4 100644 --- a/app/.pipeline/lib/app.deploy.js +++ b/app/.pipeline/lib/app.deploy.js @@ -36,6 +36,8 @@ const appDeploy = async (settings) => { REACT_APP_KEYCLOAK_HOST: phases[phase].sso.host, REACT_APP_KEYCLOAK_REALM: phases[phase].sso.realm, REACT_APP_KEYCLOAK_CLIENT_ID: phases[phase].sso.clientId, + // Feature Flags + REACT_APP_FEATURE_FLAGS: phases[phase].featureFlags, // Openshift Resources CPU_REQUEST: phases[phase].cpuRequest, CPU_LIMIT: phases[phase].cpuLimit, @@ -43,7 +45,6 @@ const appDeploy = async (settings) => { MEMORY_LIMIT: phases[phase].memoryLimit, REPLICAS: phases[phase].replicas, REPLICAS_MAX: phases[phase].replicasMax, - REACT_APP_BIOHUB_FEATURE_FLAG: phases[phase].biohubFeatureFlag, REACT_APP_BACKBONE_PUBLIC_API_HOST: phases[phase].backbonePublicApiHost, REACT_APP_BIOHUB_TAXON_PATH: phases[phase].biohubTaxonPath, REACT_APP_BIOHUB_TAXON_TSN_PATH: phases[phase].biohubTaxonTsnPath diff --git a/app/.pipeline/templates/app.dc.yaml b/app/.pipeline/templates/app.dc.yaml index b5dc3a60c9..a60dbf5044 100644 --- a/app/.pipeline/templates/app.dc.yaml +++ b/app/.pipeline/templates/app.dc.yaml @@ -14,9 +14,6 @@ parameters: - name: HOST - name: CHANGE_ID value: '0' - - name: REACT_APP_BIOHUB_FEATURE_FLAG - description: Flag to indicate if the application has biohub specific features enabled - value: 'false' - name: REACT_APP_API_HOST description: API host for application backend value: '' @@ -59,6 +56,9 @@ parameters: description: Taxon path for biohub - name: REACT_APP_BIOHUB_TAXON_TSN_PATH description: Taxon TSN path for biohub + - name: REACT_APP_FEATURE_FLAGS + description: Used to identify features that should be temporarily disabled/hidden. Must be a comma delimited list of keywords. + value: '' - name: CPU_REQUEST value: '50m' - name: CPU_LIMIT @@ -145,8 +145,6 @@ objects: name: ${OBJECT_STORE_SECRETS} - name: NODE_ENV value: ${NODE_ENV} - - name: REACT_APP_BIOHUB_FEATURE_FLAG - value: ${REACT_APP_BIOHUB_FEATURE_FLAG} - name: REACT_APP_NODE_ENV value: ${REACT_APP_NODE_ENV} - name: VERSION @@ -163,6 +161,8 @@ objects: value: ${REACT_APP_BIOHUB_TAXON_PATH} - name: REACT_APP_BIOHUB_TAXON_TSN_PATH value: ${REACT_APP_BIOHUB_TAXON_TSN_PATH} + - name: REACT_APP_FEATURE_FLAGS + value: ${REACT_APP_FEATURE_FLAGS} image: ' ' imagePullPolicy: Always ports: diff --git a/app/server/index.js b/app/server/index.js index a00b9a22c1..0a125b673c 100644 --- a/app/server/index.js +++ b/app/server/index.js @@ -38,44 +38,71 @@ const request = require('request'); // Express APP const app = express(); // Getting Port - const port = process.env.APP_PORT || 7100; + const port = process.env.APP_PORT; // Resource path const resourcePath = path.resolve(__dirname, '../build'); // Setting express static app.use(express.static(resourcePath)); + /** + * Parses a valid feature flag string into an array of feature flag strings. + * + * @param {string} featureFlagsString + * @return {*} {string[]} + */ + const parseFeatureFlagsString = (featureFlagsString) => { + if (!featureFlagsString) { + return []; + } + + return featureFlagsString.split(','); + }; + // App config app.use('/config', (_, resp) => { - const OBJECT_STORE_URL = process.env.OBJECT_STORE_URL || 'nrs.objectstore.gov.bc.ca'; - const OBJECT_STORE_BUCKET_NAME = process.env.OBJECT_STORE_BUCKET_NAME || 'gblhvt'; + const OBJECT_STORE_URL = process.env.OBJECT_STORE_URL; + const OBJECT_STORE_BUCKET_NAME = process.env.OBJECT_STORE_BUCKET_NAME; const config = { - API_HOST: process.env.REACT_APP_API_HOST || 'localhost', - CHANGE_VERSION: process.env.CHANGE_VERSION || 'NA', - NODE_ENV: process.env.NODE_ENV || 'development', - REACT_APP_NODE_ENV: process.env.REACT_APP_NODE_ENV || 'dev', - VERSION: `${process.env.VERSION || 'NA'}(build #${process.env.CHANGE_VERSION || 'NA'})`, + API_HOST: process.env.REACT_APP_API_HOST, + CHANGE_VERSION: process.env.CHANGE_VERSION, + NODE_ENV: process.env.NODE_ENV, + REACT_APP_NODE_ENV: process.env.REACT_APP_NODE_ENV, + VERSION: `${process.env.VERSION}(build #${process.env.CHANGE_VERSION})`, KEYCLOAK_CONFIG: { authority: process.env.REACT_APP_KEYCLOAK_HOST, realm: process.env.REACT_APP_KEYCLOAK_REALM, clientId: process.env.REACT_APP_KEYCLOAK_CLIENT_ID }, SITEMINDER_LOGOUT_URL: process.env.REACT_APP_SITEMINDER_LOGOUT_URL, - MAX_UPLOAD_NUM_FILES: Number(process.env.REACT_APP_MAX_UPLOAD_NUM_FILES) || 10, - MAX_UPLOAD_FILE_SIZE: Number(process.env.REACT_APP_MAX_UPLOAD_FILE_SIZE) || 52428800, + /** + * File upload settings + */ + MAX_UPLOAD_NUM_FILES: Number(process.env.REACT_APP_MAX_UPLOAD_NUM_FILES), + MAX_UPLOAD_FILE_SIZE: Number(process.env.REACT_APP_MAX_UPLOAD_FILE_SIZE), S3_PUBLIC_HOST_URL: `https://${OBJECT_STORE_URL}/${OBJECT_STORE_BUCKET_NAME}`, - BIOHUB_FEATURE_FLAG: process.env.REACT_APP_BIOHUB_FEATURE_FLAG === 'true', - BACKBONE_PUBLIC_API_HOST: process.env.REACT_APP_BACKBONE_PUBLIC_API_HOST || '', - BIOHUB_TAXON_PATH: process.env.REACT_APP_BIOHUB_TAXON_PATH || '', - BIOHUB_TAXON_TSN_PATH: process.env.REACT_APP_BIOHUB_TAXON_TSN_PATH || '' + /** + * BioHub settings + */ + BACKBONE_PUBLIC_API_HOST: process.env.REACT_APP_BACKBONE_PUBLIC_API_HOST, + BIOHUB_TAXON_PATH: process.env.REACT_APP_BIOHUB_TAXON_PATH, + BIOHUB_TAXON_TSN_PATH: process.env.REACT_APP_BIOHUB_TAXON_TSN_PATH, + /** + * Feature flags + * + * Note: Recommend conforming to a consistent pattern when defining feature flags, to make feature flags easy to + * identify (ie: `[APP/API]_FF_`) + */ + FEATURE_FLAGS: parseFeatureFlagsString(process.env.REACT_APP_FEATURE_FLAGS) }; + resp.status(200).json(config); }); // Health check app.use('/healthcheck', (_, resp) => { // Request server api - const host = process.env.REACT_APP_API_HOST || process.env.LOCAL_API_HOST || 'localhost'; + const host = process.env.REACT_APP_API_HOST; request(`https://${host}/`, (err, res) => { if (err) { console.log(`Error: ${err}, host: ${host}`); diff --git a/app/src/components/dialog/attachments/ReportAttachmentDetails.tsx b/app/src/components/dialog/attachments/ReportAttachmentDetails.tsx index 82bfa42d56..2ea891bfa0 100644 --- a/app/src/components/dialog/attachments/ReportAttachmentDetails.tsx +++ b/app/src/components/dialog/attachments/ReportAttachmentDetails.tsx @@ -16,7 +16,7 @@ const useStyles = () => { return { docTitle: { display: '-webkit-box', - WebkitLineClamp: '2', + WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }, diff --git a/app/src/components/file-upload/DropZone.test.tsx b/app/src/components/file-upload/DropZone.test.tsx index 70b181d710..6c867b4d83 100644 --- a/app/src/components/file-upload/DropZone.test.tsx +++ b/app/src/components/file-upload/DropZone.test.tsx @@ -1,10 +1,15 @@ +import { ConfigContext, IConfig } from 'contexts/configContext'; import { fireEvent, render, waitFor } from 'test-helpers/test-utils'; import DropZone from './DropZone'; const onFiles = jest.fn(); const renderContainer = () => { - return render(); + return render( + + + + ); }; describe('DropZone', () => { diff --git a/app/src/components/file-upload/DropZone.tsx b/app/src/components/file-upload/DropZone.tsx index dd6f5a612b..953b49c418 100644 --- a/app/src/components/file-upload/DropZone.tsx +++ b/app/src/components/file-upload/DropZone.tsx @@ -3,8 +3,8 @@ import Icon from '@mdi/react'; import Box from '@mui/material/Box'; import Link from '@mui/material/Link'; import Typography from '@mui/material/Typography'; -import { ConfigContext } from 'contexts/configContext'; -import React, { useContext } from 'react'; +import { useConfigContext } from 'hooks/useContext'; +import React from 'react'; import Dropzone, { FileRejection } from 'react-dropzone'; const BYTES_PER_MEGABYTE = 1048576; @@ -57,10 +57,10 @@ export interface IDropZoneConfigProps { } export const DropZone: React.FC = (props) => { - const config = useContext(ConfigContext); + const config = useConfigContext(); - const maxNumFiles = props.maxNumFiles || config?.MAX_UPLOAD_NUM_FILES; - const maxFileSize = props.maxFileSize || config?.MAX_UPLOAD_FILE_SIZE; + const maxNumFiles = props.maxNumFiles || config.MAX_UPLOAD_NUM_FILES; + const maxFileSize = props.maxFileSize || config.MAX_UPLOAD_FILE_SIZE; const multiple = props.multiple ?? true; const acceptedFileExtensions = props.acceptedFileExtensions; diff --git a/app/src/components/file-upload/FileUpload.test.tsx b/app/src/components/file-upload/FileUpload.test.tsx index 3e7abd462a..6d98033b96 100644 --- a/app/src/components/file-upload/FileUpload.test.tsx +++ b/app/src/components/file-upload/FileUpload.test.tsx @@ -1,8 +1,13 @@ +import { ConfigContext, IConfig } from 'contexts/configContext'; import { fireEvent, render, waitFor } from 'test-helpers/test-utils'; import FileUpload, { IFileUploadProps } from './FileUpload'; const renderContainer = (props: IFileUploadProps) => { - return render(); + return render( + + + + ); }; describe('FileUpload', () => { diff --git a/app/src/components/security/Guards.tsx b/app/src/components/security/Guards.tsx index 560e6ec910..d97b689d11 100644 --- a/app/src/components/security/Guards.tsx +++ b/app/src/components/security/Guards.tsx @@ -1,6 +1,7 @@ import { PROJECT_PERMISSION, PROJECT_ROLE, SYSTEM_ROLE } from 'constants/roles'; import { ProjectAuthStateContext } from 'contexts/projectAuthStateContext'; import { useAuthStateContext } from 'hooks/useAuthStateContext'; +import { useConfigContext } from 'hooks/useContext'; import { PropsWithChildren, ReactElement, useContext } from 'react'; import { hasAtLeastOneValidValue } from 'utils/authUtils'; @@ -48,6 +49,16 @@ export interface IProjectRoleGuardProps extends IGuardProps { validProjectPermissions: PROJECT_PERMISSION[]; } +export interface IFeatureFlagGuardProps extends IGuardProps { + /** + * An array of feature flag names. + * + * @type {string[]} + * @memberof IFeatureFlagGuardProps + */ + featureFlags: string[]; +} + /** * Renders `props.children` only if the user is authenticated and has at least 1 of the specified valid system roles. * @@ -63,9 +74,9 @@ export const SystemRoleGuard = (props: PropsWithChildren) if (!hasSystemRole) { if (props.fallback) { return <>{props.fallback}; - } else { - return <>; } + + return <>; } return <>{props.children}; @@ -108,9 +119,9 @@ export const AuthGuard = (props: PropsWithChildren) => { if (!authStateContext.auth.isAuthenticated || authStateContext.simsUserWrapper.isLoading) { if (props.fallback) { return <>{props.fallback}; - } else { - return <>; } + + return <>; } return <>{props.children}; @@ -128,10 +139,42 @@ export const UnAuthGuard = (props: PropsWithChildren) => { if (authStateContext.auth.isAuthenticated) { if (props.fallback) { return <>{props.fallback}; - } else { - return <>; } + + return <>; + } + + return <>{props.children}; +}; + +/** + * Renders `props.children` only if all specified feature flags do not exist. + * + * If at least one feature flag exists, the fallback will be rendered, or nothing if no fallback is provided. + * + * Feature flags are used to disable child components. to enabled a child component, simply remove all associated + * feature flags from the config (via the `REACT_APP_FEATURE_FLAGS` env var). + * + * Note: Recommend conforming to a consistent pattern when defining feature flags, to make feature flags easy to + * identify (ie: `[APP/API]_FF_`) + * + * @param {*} props + * @return {*} + */ +export const FeatureFlagGuard = (props: PropsWithChildren) => { + const config = useConfigContext(); + + const matchingFeatureFlags = config.FEATURE_FLAGS.filter((featureFlag) => props.featureFlags.includes(featureFlag)); + + if (matchingFeatureFlags.length) { + // Found at least one matching feature flag, render the fallback or nothing if no fallback is provided. + if (props.fallback) { + return <>{props.fallback}; + } + + return <>; } + // No matching feature flags found, render the children. return <>{props.children}; }; diff --git a/app/src/components/security/RouteGuards.test.tsx b/app/src/components/security/RouteGuards.test.tsx index 68f6a06941..085cb170e0 100644 --- a/app/src/components/security/RouteGuards.test.tsx +++ b/app/src/components/security/RouteGuards.test.tsx @@ -1,6 +1,8 @@ +import { FeatureFlagGuard } from 'components/security/Guards'; import { AuthenticatedRouteGuard, SystemRoleRouteGuard } from 'components/security/RouteGuards'; import { SYSTEM_ROLE } from 'constants/roles'; import { AuthStateContext } from 'contexts/authStateContext'; +import { ConfigContext, IConfig } from 'contexts/configContext'; import { createMemoryHistory } from 'history'; import { AuthContextProps } from 'react-oidc-context'; import { Router } from 'react-router'; @@ -605,4 +607,81 @@ describe('RouteGuards', () => { }); }); }); + + describe('FeatureFlagGuard', () => { + describe('feature flag guard specifies no flags', () => { + afterAll(() => { + cleanup(); + }); + + it('renders the child component', async () => { + const { getByTestId } = render( + + }> + + + + ); + + await waitFor(() => { + expect(getByTestId('success-component')).toBeVisible(); + }); + }); + }); + + describe('feature flag guard specifies no matching flags', () => { + afterAll(() => { + cleanup(); + }); + + it('renders the child component', async () => { + const { getByTestId } = render( + + }> + + + + ); + + await waitFor(() => { + expect(getByTestId('success-component')).toBeVisible(); + }); + }); + }); + + describe('feature flag guard specifies a matching flag', () => { + afterAll(() => { + cleanup(); + }); + + it('renders the fallback component', async () => { + const { getByTestId } = render( + + }> + + + + ); + + await waitFor(() => { + expect(getByTestId('success-component')).toBeVisible(); + }); + }); + }); + }); }); diff --git a/app/src/contexts/configContext.tsx b/app/src/contexts/configContext.tsx index 9f0a6089ac..842423d28e 100644 --- a/app/src/contexts/configContext.tsx +++ b/app/src/contexts/configContext.tsx @@ -17,32 +17,33 @@ export interface IConfig { MAX_UPLOAD_NUM_FILES: number; MAX_UPLOAD_FILE_SIZE: number; S3_PUBLIC_HOST_URL: string; - BIOHUB_FEATURE_FLAG: boolean; BACKBONE_PUBLIC_API_HOST: string; BIOHUB_TAXON_PATH: string; BIOHUB_TAXON_TSN_PATH: string; + /** + * Used in conjunction with the feature flag guard (FeatureFlagGuard) to disable components. + * + * @type {string[]} + * @memberof IConfig + */ + FEATURE_FLAGS: string[]; } -export const ConfigContext = React.createContext({ - API_HOST: '', - CHANGE_VERSION: '', - NODE_ENV: '', - REACT_APP_NODE_ENV: '', - VERSION: '', - KEYCLOAK_CONFIG: { - authority: '', - realm: '', - clientId: '' - }, - SITEMINDER_LOGOUT_URL: '', - MAX_UPLOAD_NUM_FILES: 10, - MAX_UPLOAD_FILE_SIZE: 52428800, - S3_PUBLIC_HOST_URL: '', - BIOHUB_FEATURE_FLAG: false, - BACKBONE_PUBLIC_API_HOST: '', - BIOHUB_TAXON_PATH: '', - BIOHUB_TAXON_TSN_PATH: '' -}); +export const ConfigContext = React.createContext(undefined); + +/** + * Parses a valid feature flag string into an array of feature flag strings. + * + * @param {string} featureFlagsString + * @return {*} {string[]} + */ +const parseFeatureFlagsString = (featureFlagsString: string): string[] => { + if (!featureFlagsString) { + return []; + } + + return featureFlagsString.split(','); +}; /** * Return the app config based on locally set environment variables. @@ -74,13 +75,25 @@ const getLocalConfig = (): IConfig => { clientId: process.env.REACT_APP_KEYCLOAK_CLIENT_ID || '' }, SITEMINDER_LOGOUT_URL: process.env.REACT_APP_SITEMINDER_LOGOUT_URL || '', + /** + * File upload settings + */ MAX_UPLOAD_NUM_FILES: Number(process.env.REACT_APP_MAX_UPLOAD_NUM_FILES) || 10, MAX_UPLOAD_FILE_SIZE: Number(process.env.REACT_APP_MAX_UPLOAD_FILE_SIZE) || 52428800, S3_PUBLIC_HOST_URL: ensureProtocol(`${OBJECT_STORE_URL}/${OBJECT_STORE_BUCKET_NAME}`, 'https://'), - BIOHUB_FEATURE_FLAG: process.env.REACT_APP_BIOHUB_FEATURE_FLAG === 'true', + /** + * BioHub settings + */ BACKBONE_PUBLIC_API_HOST: process.env.REACT_APP_BACKBONE_PUBLIC_API_HOST || '', BIOHUB_TAXON_PATH: process.env.REACT_APP_BIOHUB_TAXON_PATH || '', - BIOHUB_TAXON_TSN_PATH: process.env.REACT_APP_BIOHUB_TAXON_TSN_PATH || '' + BIOHUB_TAXON_TSN_PATH: process.env.REACT_APP_BIOHUB_TAXON_TSN_PATH || '', + /** + * Feature flags + * + * Note: Recommend conforming to a consistent pattern when defining feature flags, to make feature flags easy to + * identify (ie: `[APP/API]_FF_`) + */ + FEATURE_FLAGS: parseFeatureFlagsString(process.env.REACT_APP_FEATURE_FLAGS || '') }; }; diff --git a/app/src/features/projects/view/ProjectAttachments.test.tsx b/app/src/features/projects/view/ProjectAttachments.test.tsx index ad7fca0140..fea9714a5f 100644 --- a/app/src/features/projects/view/ProjectAttachments.test.tsx +++ b/app/src/features/projects/view/ProjectAttachments.test.tsx @@ -1,5 +1,6 @@ import { AttachmentType } from 'constants/attachments'; import { AuthStateContext } from 'contexts/authStateContext'; +import { ConfigContext, IConfig } from 'contexts/configContext'; import { DialogContextProvider } from 'contexts/dialogContext'; import { IProjectAuthStateContext, ProjectAuthStateContext } from 'contexts/projectAuthStateContext'; import { IProjectContext, ProjectContext } from 'contexts/projectContext'; @@ -66,15 +67,17 @@ describe('ProjectAttachments', () => { }; const { getByText, queryByText } = render( - - - - - - - - - + + + + + + + + + + + ); await waitFor(() => { @@ -117,15 +120,17 @@ describe('ProjectAttachments', () => { }; const { getByText } = render( - - - - - - - - - + + + + + + + + + + + ); await waitFor(() => { expect(getByText('No shared files found')).toBeInTheDocument(); @@ -165,15 +170,17 @@ describe('ProjectAttachments', () => { }; const { getByText } = render( - - - - - - - - - + + + + + + + + + + + ); await waitFor(() => { @@ -224,17 +231,19 @@ describe('ProjectAttachments', () => { }; const { baseElement, queryByText, getByTestId, getAllByTestId, queryByTestId } = render( - - - - - - - - - - - + + + + + + + + + + + + + ); await waitFor(() => { @@ -296,17 +305,19 @@ describe('ProjectAttachments', () => { }; const { baseElement, queryByText, getByTestId, queryByTestId, getAllByTestId } = render( - - - - - - - - - - - + + + + + + + + + + + + + ); await waitFor(() => { @@ -370,17 +381,19 @@ describe('ProjectAttachments', () => { }; const { baseElement, queryByText, getAllByRole, queryByTestId, getAllByTestId } = render( - - - - - - - - - - - + + + + + + + + + + + + + ); await waitFor(() => { diff --git a/app/src/features/resources/ResourcesPage.tsx b/app/src/features/resources/ResourcesPage.tsx index 8aa5e091a5..27eb71ca13 100644 --- a/app/src/features/resources/ResourcesPage.tsx +++ b/app/src/features/resources/ResourcesPage.tsx @@ -31,7 +31,7 @@ const useStyles = () => { }, pageTitle: { display: '-webkit-box', - WebkitLineClamp: '2', + WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', paddingTop: theme.spacing(0.5), paddingBottom: theme.spacing(0.5), diff --git a/app/src/features/surveys/view/SurveyAttachments.test.tsx b/app/src/features/surveys/view/SurveyAttachments.test.tsx index 46cc009ebe..5c796e4f7c 100644 --- a/app/src/features/surveys/view/SurveyAttachments.test.tsx +++ b/app/src/features/surveys/view/SurveyAttachments.test.tsx @@ -1,5 +1,6 @@ import { AttachmentType } from 'constants/attachments'; import { AuthStateContext } from 'contexts/authStateContext'; +import { ConfigContext, IConfig } from 'contexts/configContext'; import { DialogContextProvider } from 'contexts/dialogContext'; import { IProjectAuthStateContext, ProjectAuthStateContext } from 'contexts/projectAuthStateContext'; import { IProjectContext, ProjectContext } from 'contexts/projectContext'; @@ -83,17 +84,19 @@ describe('SurveyAttachments', () => { } as unknown as IProjectContext; const { getByText, queryByText } = render( - - - - - - - - - - - + + + + + + + + + + + + + ); await waitFor(() => { expect(getByText('Upload')).toBeInTheDocument(); @@ -150,17 +153,19 @@ describe('SurveyAttachments', () => { } as unknown as IProjectContext; const { getByText } = render( - - - - - - - - - - - + + + + + + + + + + + + + ); await waitFor(() => { expect(getByText('No documents found')).toBeInTheDocument(); @@ -215,17 +220,19 @@ describe('SurveyAttachments', () => { } as unknown as IProjectContext; const { getByText } = render( - - - - - - - - - - - + + + + + + + + + + + + + ); await waitFor(() => { @@ -289,19 +296,21 @@ describe('SurveyAttachments', () => { } as unknown as IProjectContext; const { baseElement, queryByText, getByTestId, getAllByTestId, queryByTestId } = render( - - - - - - - - - - - - - + + + + + + + + + + + + + + + ); await waitFor(() => { @@ -380,19 +389,21 @@ describe('SurveyAttachments', () => { } as unknown as IProjectContext; const { baseElement, queryByText, getByTestId, getAllByTestId, queryByTestId } = render( - - - - - - - - - - - - - + + + + + + + + + + + + + + + ); await waitFor(() => { @@ -470,19 +481,21 @@ describe('SurveyAttachments', () => { }; const { baseElement, queryByText, getAllByTestId, queryByTestId, getAllByRole } = render( - - - - - - - - - - - - - + + + + + + + + + + + + + + + ); await waitFor(() => { diff --git a/app/src/features/surveys/view/SurveyHeader.test.tsx b/app/src/features/surveys/view/SurveyHeader.test.tsx index b54f4d84b6..8e06ac5f95 100644 --- a/app/src/features/surveys/view/SurveyHeader.test.tsx +++ b/app/src/features/surveys/view/SurveyHeader.test.tsx @@ -1,4 +1,5 @@ import { AuthStateContext, IAuthState } from 'contexts/authStateContext'; +import { ConfigContext, IConfig } from 'contexts/configContext'; import { DialogContextProvider } from 'contexts/dialogContext'; import { IProjectAuthStateContext, ProjectAuthStateContext } from 'contexts/projectAuthStateContext'; import { IProjectContext, ProjectContext } from 'contexts/projectContext'; @@ -96,17 +97,19 @@ describe('SurveyHeader', () => { const renderComponent = (authState: IAuthState, projectAuthState: IProjectAuthStateContext) => { return render( - - - - - - - - - - - + + + + + + + + + + + + + ); }; diff --git a/app/src/features/surveys/view/SurveyHeader.tsx b/app/src/features/surveys/view/SurveyHeader.tsx index cad79bf72b..d08ed75c26 100644 --- a/app/src/features/surveys/view/SurveyHeader.tsx +++ b/app/src/features/surveys/view/SurveyHeader.tsx @@ -14,11 +14,10 @@ import Typography from '@mui/material/Typography'; import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; import PageHeader from 'components/layout/PageHeader'; import PublishSurveyIdDialog from 'components/publish/PublishSurveyDialog'; -import { ProjectRoleGuard } from 'components/security/Guards'; +import { FeatureFlagGuard, ProjectRoleGuard } from 'components/security/Guards'; import { DATE_FORMAT } from 'constants/dateTimeFormats'; import { DeleteSurveyI18N } from 'constants/i18n'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from 'constants/roles'; -import { ConfigContext } from 'contexts/configContext'; import { DialogContext } from 'contexts/dialogContext'; import { ProjectContext } from 'contexts/projectContext'; import { SurveyContext } from 'contexts/surveyContext'; @@ -38,7 +37,6 @@ import { SurveyProgressChip } from '../components/SurveyProgressChip'; const SurveyHeader = () => { const surveyContext = useContext(SurveyContext); const projectContext = useContext(ProjectContext); - const configContext = useContext(ConfigContext); const surveyWithDetails = surveyContext.surveyDataLoader.data; const projectWithDetails = projectContext.projectDataLoader.data; @@ -123,8 +121,6 @@ const SurveyHeader = () => { const publishDate = surveyWithDetails.surveySupplementaryData.survey_metadata_publish?.event_timestamp.split(' ')[0]; - const BIOHUB_FEATURE_FLAG = configContext?.BIOHUB_FEATURE_FLAG; - return ( <> { validProjectPermissions={[PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR]} validSystemRoles={[SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR]}> - {BIOHUB_FEATURE_FLAG && ( + @@ -195,7 +191,7 @@ const SurveyHeader = () => { Publish - )} +