diff --git a/api/apps/api/src/modules/scenarios-features/scenario-features-gap-data.service.ts b/api/apps/api/src/modules/scenarios-features/scenario-features-gap-data.service.ts index 96eaf426f0..daf9e6ba75 100644 --- a/api/apps/api/src/modules/scenarios-features/scenario-features-gap-data.service.ts +++ b/api/apps/api/src/modules/scenarios-features/scenario-features-gap-data.service.ts @@ -17,6 +17,7 @@ import { ScenarioAccessControl } from '@marxan-api/modules/access-control/scenar import { assertDefined } from '@marxan/utils'; import { forbiddenError } from '@marxan-api/modules/access-control'; import { Either, left, right } from 'fp-ts/lib/Either'; +import { plainToClass } from 'class-transformer'; @Injectable() export class ScenarioFeaturesGapDataService extends AppBaseService< @@ -59,7 +60,19 @@ export class ScenarioFeaturesGapDataService extends AppBaseService< ) { return left(forbiddenError); } - return right(await super.findAllPaginated(fetchSpecification, info)); + /** + * @debt Explicitly applying transforms (via `plainToClass()`) here: it + * would be best to do this at AppBaseService level, but that would + * currently open a rabbit hole due to the use of generics. + */ + const { data, metadata } = await this.findAllPaginated( + fetchSpecification, + info, + ); + return right({ + data: plainToClass(ScenarioFeaturesGapData, data), + metadata, + }); } async setFilters( diff --git a/api/apps/api/src/modules/scenarios-features/scenario-features-output-gap-data.service.ts b/api/apps/api/src/modules/scenarios-features/scenario-features-output-gap-data.service.ts index 547bf69279..b945f35602 100644 --- a/api/apps/api/src/modules/scenarios-features/scenario-features-output-gap-data.service.ts +++ b/api/apps/api/src/modules/scenarios-features/scenario-features-output-gap-data.service.ts @@ -18,6 +18,7 @@ import { forbiddenError } from '@marxan-api/modules/access-control'; import { assertDefined } from '@marxan/utils'; import { Either, left, right } from 'fp-ts/lib/Either'; import { ScenarioAccessControl } from '@marxan-api/modules/access-control/scenarios-acl/scenario-access-control'; +import { plainToClass } from 'class-transformer'; const scenarioFeaturesOutputGapDataFilterKeyNames = ['runId'] as const; type ScenarioFeaturesOutputGapDataFilterKeys = keyof Pick< @@ -73,7 +74,19 @@ export class ScenarioFeaturesOutputGapDataService extends AppBaseService< ) { return left(forbiddenError); } - return right(await super.findAllPaginated(fetchSpecification, info)); + /** + * @debt Explicitly applying transforms (via `plainToClass()`) here: it + * would be best to do this at AppBaseService level, but that would + * currently open a rabbit hole due to the use of generics. + */ + const { data, metadata } = await this.findAllPaginated( + fetchSpecification, + info, + ); + return right({ + data: plainToClass(ScenarioFeaturesOutputGapData, data), + metadata, + }); } async setFilters( diff --git a/api/apps/api/src/modules/scenarios/scenarios.controller.ts b/api/apps/api/src/modules/scenarios/scenarios.controller.ts index d237ae6441..45fdb61277 100644 --- a/api/apps/api/src/modules/scenarios/scenarios.controller.ts +++ b/api/apps/api/src/modules/scenarios/scenarios.controller.ts @@ -86,7 +86,6 @@ import { GeometryKind, } from '@marxan-api/decorators/file-interceptors.decorator'; import { ProtectedAreaDto } from '@marxan-api/modules/scenarios/dto/protected-area.dto'; -import { UploadShapefileDto } from '@marxan-api/modules/scenarios/dto/upload.shapefile.dto'; import { ProtectedAreasChangeDto } from '@marxan-api/modules/scenarios/dto/protected-area-change.dto'; import { StartScenarioBlmCalibrationDto } from '@marxan-api/modules/scenarios/dto/start-scenario-blm-calibration.dto'; import { BlmCalibrationRunResultDto } from './dto/scenario-blm-calibration-results.dto'; @@ -108,8 +107,6 @@ import { RequestScenarioCloneResponseDto } from './dto/scenario-clone.dto'; import { ensureShapefileHasRequiredFiles } from '@marxan-api/utils/file-uploads.utils'; import { WebshotPdfReportConfig } from '@marxan/webshot/webshot.dto'; import { ClearLockStatusParams } from '@marxan-api/modules/scenarios/dto/clear-lock-status-param.dto'; -import { CostRangeDto } from '@marxan-api/modules/scenarios/dto/cost-range.dto'; -import { plainToClass } from 'class-transformer'; import { ProjectsService } from '@marxan-api/modules/projects/projects.service'; import { CostSurfaceService } from '@marxan-api/modules/cost-surface/cost-surface.service'; diff --git a/api/apps/geoprocessing/src/migrations/geoprocessing/1710497811000-IncludeDecimalDigitsWhenRoundingTargetAndCurrentDataInGapDataViews.ts b/api/apps/geoprocessing/src/migrations/geoprocessing/1710497811000-IncludeDecimalDigitsWhenRoundingTargetAndCurrentDataInGapDataViews.ts new file mode 100644 index 0000000000..16a0591827 --- /dev/null +++ b/api/apps/geoprocessing/src/migrations/geoprocessing/1710497811000-IncludeDecimalDigitsWhenRoundingTargetAndCurrentDataInGapDataViews.ts @@ -0,0 +1,147 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class IncludeDecimalDigitsWhenRoundingTargetAndCurrentDataInGapDataViews1710497811000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` +drop view scenario_features_gap_data; + +create view scenario_features_gap_data as + with gap_data as ( + select + sfd.api_feature_id as feature_id, + scenario_id, + sum(total_area) total_area, + case when sum(current_pa) is not null + then sum(current_pa) + else 0 + end as met_area, + min(prop) as coverage_target + from scenario_features_data sfd + group by sfd.api_feature_id, feature_class_id, scenario_id) + select + scenario_id, + feature_id, + sum(total_area) as total_area, + sum(met_area) as met_area, + case + when sum(total_area) > 0 + then round(((sum(met_area)/sum(total_area))*100)::numeric, 4) + else 0 + end as met, + sum(total_area) * min(coverage_target) as coverage_target_area, + round((min(coverage_target) * 100)::numeric, 4) as coverage_target, + sum(met_area) >= (sum(total_area) * min(coverage_target)) as on_target + from gap_data + group by feature_id, scenario_id; + `); + + await queryRunner.query(` +drop view scenario_features_output_gap_data; + +create view scenario_features_output_gap_data as + with gap_data as ( + select + amount, + occurrences, + run_id, + sfd.api_feature_id as feature_id, + sfd.scenario_id, + osfd.total_area, + sfd.prop as coverage_target, + osfd.target as target_met + from output_scenarios_features_data osfd + inner join scenario_features_data sfd on osfd.scenario_features_id=sfd.id) + select + scenario_id, + feature_id, + sum(total_area) as total_area, + sum(amount) as met_area, + case + when sum(total_area) <> 0 and sum(total_area) is not null then + round(((sum(amount)/sum(total_area))*100)::numeric, 4) + else + 0 + end as met, + sum(occurrences) as met_occurrences, + sum(total_area) * min(coverage_target) as coverage_target_area, + round((min(coverage_target) * 100)::numeric, 4) as coverage_target, + bool_and(target_met) as on_target, + run_id + from gap_data + group by run_id, feature_id, scenario_id; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` +drop view scenario_features_gap_data; + +create view scenario_features_gap_data as + with gap_data as ( + select + sfd.api_feature_id as feature_id, + scenario_id, + sum(total_area) total_area, + case when sum(current_pa) is not null + then sum(current_pa) + else 0 + end as met_area, + min(prop) as coverage_target + from scenario_features_data sfd + group by sfd.api_feature_id, feature_class_id, scenario_id) + select + scenario_id, + feature_id, + sum(total_area) as total_area, + sum(met_area) as met_area, + case + when sum(total_area) > 0 + then round((sum(met_area)/sum(total_area))*100) + else 0 + end as met, + sum(total_area) * min(coverage_target) as coverage_target_area, + round(min(coverage_target) * 100) as coverage_target, + sum(met_area) >= (sum(total_area) * min(coverage_target)) as on_target + from gap_data + group by feature_id, scenario_id; + `); + + await queryRunner.query(` + drop view scenario_features_output_gap_data; + + create view scenario_features_output_gap_data as + with gap_data as ( + select + amount, + occurrences, + run_id, + sfd.api_feature_id as feature_id, + sfd.scenario_id, + osfd.total_area, + sfd.prop as coverage_target, + osfd.target as target_met + from output_scenarios_features_data osfd + inner join scenario_features_data sfd on osfd.scenario_features_id=sfd.id) + select + scenario_id, + feature_id, + sum(total_area) as total_area, + sum(amount) as met_area, + case + when sum(total_area) <> 0 and sum(total_area) is not null then + round((sum(amount)/sum(total_area))*100) + else + 0 + end as met, + sum(occurrences) as met_occurrences, + sum(total_area) * min(coverage_target) as coverage_target_area, + round(min(coverage_target) * 100) as coverage_target, + bool_and(target_met) as on_target, + run_id + from gap_data + group by run_id, feature_id, scenario_id; + `); + } +} diff --git a/api/libs/features/src/scenario-features-gap-data.geo.entity.ts b/api/libs/features/src/scenario-features-gap-data.geo.entity.ts index a73a7f2d51..c9d4450a4b 100644 --- a/api/libs/features/src/scenario-features-gap-data.geo.entity.ts +++ b/api/libs/features/src/scenario-features-gap-data.geo.entity.ts @@ -1,4 +1,6 @@ +import { numericStringToFloat } from '@marxan/utils/numeric-string-to-float.utils'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; import { Column, ViewEntity } from 'typeorm'; @ViewEntity('scenario_features_gap_data') @@ -20,7 +22,10 @@ export class ScenarioFeaturesGapData { metArea!: number; @ApiProperty() - @Column({ name: 'met' }) + // explicitly set type, otherwise TypeORM (v10, at least) will cast to integer + // TypeORM will still represent the value as string though (https://github.com/typeorm/typeorm/issues/873#issuecomment-328912050) + @Column({ name: 'met', type: 'double precision' }) + @Transform(numericStringToFloat) met!: number; @ApiProperty() @@ -28,7 +33,10 @@ export class ScenarioFeaturesGapData { coverageTargetArea!: number; @ApiProperty() - @Column({ name: 'coverage_target' }) + // explicitly set type, otherwise TypeORM (v10, at least) will cast to integer + // TypeORM will still represent the value as string though (https://github.com/typeorm/typeorm/issues/873#issuecomment-328912050) + @Column({ name: 'coverage_target', type: 'double precision' }) + @Transform(numericStringToFloat) coverageTarget!: number; @ApiProperty() diff --git a/api/libs/utils/src/numeric-string-to-float.utils.spec.ts b/api/libs/utils/src/numeric-string-to-float.utils.spec.ts new file mode 100644 index 0000000000..1bddc129b3 --- /dev/null +++ b/api/libs/utils/src/numeric-string-to-float.utils.spec.ts @@ -0,0 +1,21 @@ +import { numericStringToFloat } from './numeric-string-to-float.utils'; + +describe('parseOptionalFloat', () => { + it('should return undefined if the value is undefined', () => { + expect(numericStringToFloat(undefined)).toBeUndefined(); + }); + + it('should throw an exception if the value is not numeric', () => { + expect(() => numericStringToFloat('foo')).toThrow('Invalid number: foo'); + }); + + it('should return a numeric representation of the value if the value is numeric', () => { + expect(numericStringToFloat('123.456')).toBe(123.456); + }); + + it('should silently round a float to the maximum precision supported by javascript', () => { + expect(numericStringToFloat('123.456789012345678901234567890')).toBe( + 123.45678901234568, + ); + }); +}); diff --git a/api/libs/utils/src/numeric-string-to-float.utils.ts b/api/libs/utils/src/numeric-string-to-float.utils.ts new file mode 100644 index 0000000000..af3ac3fa38 --- /dev/null +++ b/api/libs/utils/src/numeric-string-to-float.utils.ts @@ -0,0 +1,24 @@ +import { isNil } from 'lodash'; + +/** + * Kind of like parseFloat(), but passing through undefined values, and handling + * values that don't cast to a number. + * + * @debt It silently rounds to the maximum precision supported by Javascript any + * input values that are numeric but beyond what can be represented in a + * Javascript number (not BigInt). Infinity and -Infinity are also passed + * through as corresponding Javascript Infinity numeric values. + */ +export function numericStringToFloat( + value: string | undefined, +): number | undefined { + // +(null) === 0, so we only cast if input is neither undefined nor null. + if (!isNil(value)) { + const floatValue = +value; + if (!isNaN(floatValue)) { + return floatValue; + } + throw new Error(`Invalid number: ${value}`); + } + return; +} diff --git a/app/components/gap-analysis/item/component.tsx b/app/components/gap-analysis/item/component.tsx index c53f21b6b9..0416b391c9 100644 --- a/app/components/gap-analysis/item/component.tsx +++ b/app/components/gap-analysis/item/component.tsx @@ -41,7 +41,7 @@ export const Item: React.FC = ({ }: ItemProps) => { const chartRef = useRef(null); const [chartEl, setChartEl] = useState(null); - const percentFormatter = useNumberFormatter({ style: 'percent' }); + const percentFormatter = useNumberFormatter({ style: 'percent', maximumFractionDigits: 4 }); const metStyles = useMemo(() => { if (chartEl) { diff --git a/app/layout/project/sidebar/project/inventory-panel/features/modals/upload/index.tsx b/app/layout/project/sidebar/project/inventory-panel/features/modals/upload/index.tsx index a97474cb89..8ca66072dd 100644 --- a/app/layout/project/sidebar/project/inventory-panel/features/modals/upload/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/features/modals/upload/index.tsx @@ -175,8 +175,8 @@ export const FeatureUploadModal = ({ <>

Upload in progress

- CSV file uploaded correctly. Starting features processing. This might take several - minutes. + {uploadMode === 'csv' ? 'CSV file' : 'Shapefile'} uploaded correctly. Starting + features processing. This might take several minutes.

, { @@ -197,7 +197,7 @@ export const FeatureUploadModal = ({ setSuccessFile(null); addToast( - 'error-upload-feature-csv', + 'error-upload-feature-file', <>

Error

    diff --git a/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/bulk-action-menu/index.tsx b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/bulk-action-menu/index.tsx index e64fa073f8..73d7c21ccf 100644 --- a/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/bulk-action-menu/index.tsx +++ b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/bulk-action-menu/index.tsx @@ -26,7 +26,7 @@ const SplitFeaturesBulkActionMenu = ({ }: { features: (Feature & { name: string; marxanSettings: { prop?: number; fpf?: number } })[]; selectedFeatureIds: Feature['id'][]; - onDone: () => void; + onDone: (res?: unknown) => void; }): JSX.Element => { const { query } = useRouter(); const { pid, sid } = query as { pid: string; sid: string }; diff --git a/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/bulk-action-menu/modals/delete/index.tsx b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/bulk-action-menu/modals/delete/index.tsx index b19737567f..843f9e2d4e 100644 --- a/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/bulk-action-menu/modals/delete/index.tsx +++ b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/bulk-action-menu/modals/delete/index.tsx @@ -19,7 +19,7 @@ const DeleteModal = ({ features: any[]; selectedFeaturesIds: Feature['id'][]; onDismiss?: ModalProps['onDismiss']; - onDone?: () => void; + onDone?: (res?: unknown) => void; }): JSX.Element => { const queryClient = useQueryClient(); const { query } = useRouter(); diff --git a/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/bulk-action-menu/modals/edit/index.tsx b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/bulk-action-menu/modals/edit/index.tsx index 6619c98e5c..48add96f66 100644 --- a/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/bulk-action-menu/modals/edit/index.tsx +++ b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/bulk-action-menu/modals/edit/index.tsx @@ -10,14 +10,22 @@ import { useToasts } from 'hooks/toast'; import Button from 'components/button'; import Field from 'components/forms/field'; import Label from 'components/forms/label'; +import Radio from 'components/forms/radio'; import { composeValidators } from 'components/forms/validations'; import { Feature } from 'types/api/feature'; export type FormValues = { target: number; spf: number; + mode: 'all' | 'only-target' | 'only-spf'; }; +const EDITION_MODES = [ + { value: 'all', label: 'Target & SPF' }, + { value: 'only-target', label: 'Target' }, + { value: 'only-spf', label: 'SPF' }, +]; + const INPUT_CLASSES = 'h-10 w-full rounded-md border border-gray-400 px-3 text-gray-900 focus:border-none focus:outline-none focus:ring-1 focus:ring-blue-600'; @@ -28,7 +36,7 @@ const EditModal = ({ }: { selectedFeatures: (Feature & { name: string; marxanSettings: { prop?: number; fpf?: number } })[]; handleModal: (modalKey: 'split' | 'edit' | 'delete', isVisible: boolean) => void; - onDone?: () => void; + onDone?: (res?: unknown) => void; }): JSX.Element => { const { addToast } = useToasts(); const { query } = useRouter(); @@ -75,7 +83,7 @@ const EditModal = ({ const onEditSubmit = useCallback( (values: FormValues) => { - const { target, spf = 1 } = values; + const { target, spf = 1, mode } = values; const data = { status: 'created', @@ -117,14 +125,34 @@ const EditModal = ({ }; } + let newMarxanSettings = sf.marxanSettings; + + if (mode === 'only-target') { + newMarxanSettings = { + ...newMarxanSettings, + prop: target / 100, + }; + } + + if (mode === 'only-spf') { + newMarxanSettings = { + ...newMarxanSettings, + fpf: +spf, + }; + } + + if (mode === 'all') { + newMarxanSettings = { + prop: target / 100, + fpf: +spf, + }; + } + return { featureId, kind, marxanSettings: selectedFeatures.find((f) => f.id === featureId) - ? { - prop: target / 100 || 0.5, - fpf: +spf, - } + ? newMarxanSettings : sf.marxanSettings, }; }), @@ -136,8 +164,8 @@ const EditModal = ({ data, }, { - onSuccess: () => { - onDone?.(); + onSuccess: (res) => { + onDone?.(res); handleModal('edit', false); addToast( @@ -181,65 +209,90 @@ const EditModal = ({ return ( initialValues={{ + mode: 'all', target: - (selectedFeatures?.length === 1 && selectedFeatures?.[0]?.marxanSettings?.prop) || 50, - spf: (selectedFeatures?.length === 1 && selectedFeatures?.[0]?.marxanSettings?.fpf) || 1, + (selectedFeatures?.length === 1 && selectedFeatures?.[0]?.marxanSettings?.prop) || + undefined, + spf: + (selectedFeatures?.length === 1 && selectedFeatures?.[0]?.marxanSettings?.fpf) || + undefined, }} - ref={formRef} onSubmit={onEditSubmit} render={({ form, handleSubmit }) => { formRef.current = form; + const currentMode = form?.getState()?.values?.mode; + return (

    Edit selected features

    +
    + {EDITION_MODES.map(({ value, label }) => { + return ( + + {(fprops) => ( +
    + + +
    + )} +
    + ); + })} +
    +
    - - name="target" - validate={composeValidators([{ presence: true }])} - > - {(fprops) => ( - - - - - - )} - - - - name="spf" - validate={composeValidators([{ presence: true }])} - > - {(fprops) => ( - - - - - - )} - + {['only-target', 'all'].includes(currentMode) && ( + + name="target" + validate={composeValidators([{ presence: true }])} + > + {(fprops) => ( + + + + + + )} + + )} + {['only-spf', 'all'].includes(currentMode) && ( + + name="spf" + validate={composeValidators([{ presence: true }])} + > + {(fprops) => ( + + + + + + )} + + )}
    @@ -247,7 +300,12 @@ const EditModal = ({ Cancel -
    diff --git a/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/index.tsx b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/index.tsx index b2f9c9ebe8..e35c3d46b7 100644 --- a/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/index.tsx +++ b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/index.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, ComponentProps, useCallback, useEffect, useMemo, useState } from 'react'; +import { ChangeEvent, ComponentProps, useCallback, useMemo, useState } from 'react'; import { useQueryClient } from 'react-query'; @@ -24,6 +24,7 @@ import TargetsSPFTable from 'layout/project/sidebar/scenario/grid-setup/features import ActionsMenu from 'layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/actions-menu'; import Section from 'layout/section'; import { Feature } from 'types/api/feature'; +import { toFixedWithoutZeros } from 'utils/numbers'; import CLOSE_SVG from 'svgs/ui/close.svg?sprite'; @@ -103,7 +104,7 @@ const TargetAndSPFFeatures = (): JSX.Element => { splitted: true, marxanSettings: { ...splitFeature.marxanSettings, - prop: splitFeature.marxanSettings?.prop * 100, + prop: toFixedWithoutZeros(splitFeature.marxanSettings?.prop * 100), ...(featureValues[`${feature.id}-${splitFeature.name}`]?.target && { prop: featureValues[`${feature.id}-${splitFeature.name}`].target, }), @@ -127,7 +128,7 @@ const TargetAndSPFFeatures = (): JSX.Element => { type: featureMetadata?.tag, marxanSettings: { ...feature.marxanSettings, - prop: feature.marxanSettings?.prop * 100, + prop: toFixedWithoutZeros(feature.marxanSettings?.prop * 100), ...(featureValues[feature.id]?.target && { prop: featureValues[feature.id].target, }), @@ -415,23 +416,25 @@ const TargetAndSPFFeatures = (): JSX.Element => { [selectedFeaturesQuery.data, queryClient, sid, selectedFeaturesMutation] ); - const displayBulkActions = selectedFeatureIds.length > 0; - const displaySaveButton = selectedFeaturesQuery.data?.length > 0; + const onDoneEditing = useCallback((res: { data: { features: any[] } }) => { + setSelectedFeatureIds([]); + const { features } = res?.data || {}; - useEffect(() => { - setFeatureValues((prevValues) => ({ - ...prevValues, - ...selectedFeaturesQuery.data?.reduce((acc, { id, marxanSettings }) => { + setFeatureValues(() => ({ + ...features?.reduce((acc, { featureId, marxanSettings }) => { return { ...acc, - [id]: { - target: marxanSettings?.prop * 100, + [featureId]: { + target: toFixedWithoutZeros(marxanSettings?.prop * 100), spf: marxanSettings?.fpf, }, }; }, {}), })); - }, [selectedFeaturesQuery.data]); + }, []); + + const displayBulkActions = selectedFeatureIds.length > 0; + const displaySaveButton = selectedFeaturesQuery.data?.length > 0; return ( <> @@ -524,9 +527,7 @@ const TargetAndSPFFeatures = (): JSX.Element => { { - setSelectedFeatureIds([]); - }} + onDone={onDoneEditing} /> )} diff --git a/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/index.tsx b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/index.tsx index c4bdd0a11d..852c8396c1 100644 --- a/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/index.tsx +++ b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/index.tsx @@ -57,6 +57,7 @@ const TargetsSPFTable = ({ theme="light" className="block h-4 w-4 checked:bg-blue-500" onChange={onSelectAll} + checked={selectedIds.length === data.length} disabled={noDataCustom} /> diff --git a/app/utils/numbers.ts b/app/utils/numbers.ts new file mode 100644 index 0000000000..8c24eb8cc7 --- /dev/null +++ b/app/utils/numbers.ts @@ -0,0 +1,3 @@ +export function toFixedWithoutZeros(num: number, decimalPlaces = 2): number { + return Number(num.toFixed(decimalPlaces).replace(/\.?0+$/, '')); +}