diff --git a/apps/fishing-map/features/datasets/datasets.selectors.ts b/apps/fishing-map/features/datasets/datasets.selectors.ts index 44fe00a233..e418daf3f1 100644 --- a/apps/fishing-map/features/datasets/datasets.selectors.ts +++ b/apps/fishing-map/features/datasets/datasets.selectors.ts @@ -1,6 +1,7 @@ import { createSelector } from '@reduxjs/toolkit' import { uniqBy } from 'es-toolkit' -import { DatasetCategory, DatasetTypes } from '@globalfishingwatch/api-types' +import { DatasetCategory, DatasetStatus, DatasetTypes } from '@globalfishingwatch/api-types' +import { VESSEL_GROUPS_MIN_API_VERSION } from 'features/vessel-groups/vessel-groups.config' import { selectAllDatasets } from './datasets.slice' const EMPTY_ARRAY: [] = [] @@ -25,6 +26,17 @@ const selectDatasetsByType = (type: DatasetTypes) => { export const selectFourwingsDatasets = selectDatasetsByType(DatasetTypes.Fourwings) export const selectVesselsDatasets = selectDatasetsByType(DatasetTypes.Vessels) +export const selectVesselGroupCompatibleDatasets = createSelector( + [selectVesselsDatasets], + (datasets) => { + return datasets.filter( + (d) => + d.status !== DatasetStatus.Deleted && + d.configuration?.apiSupportedVersions?.includes(VESSEL_GROUPS_MIN_API_VERSION) + ) + } +) + export const selectActivityDatasets = createSelector([selectFourwingsDatasets], (datasets) => { return datasets.filter((d) => d.category === DatasetCategory.Activity) }) diff --git a/apps/fishing-map/features/reports/events/VGREvents.tsx b/apps/fishing-map/features/reports/events/VGREvents.tsx index 4a6e6ed736..958f3b5ccf 100644 --- a/apps/fishing-map/features/reports/events/VGREvents.tsx +++ b/apps/fishing-map/features/reports/events/VGREvents.tsx @@ -29,6 +29,7 @@ import { selectVGREventsVesselsFlags, selectVGREventsVesselsGrouped, } from 'features/reports/events/vgr-events.selectors' +import { formatI18nNumber } from 'features/i18n/i18nNumber' import styles from './VGREvents.module.css' function VGREvents() { @@ -37,7 +38,7 @@ function VGREvents() { const filter = useSelector(selectVGREventsVesselFilter) const eventsDataview = useSelector(selectVGREventsSubsectionDataview) const vesselsGroupByProperty = useSelector(selectVGREventsVesselsProperty) - const vessels = useSelector(selectVGREventsVessels) + const vesselsWithEvents = useSelector(selectVGREventsVessels) const vesselFlags = useSelector(selectVGREventsVesselsFlags) const vesselGroups = useSelector(selectVGREventsVesselsGrouped) @@ -87,8 +88,8 @@ function VGREvents() { t('vesselGroup.summaryEvents', { defaultValue: '{{vessels}} vessels from {{flags}} flags had {{activityQuantity}} {{activityUnit}} globally between {{start}} and {{end}}', - vessels: vessels?.length, - flags: vesselFlags, + vessels: formatI18nNumber(vesselsWithEvents?.length || 0), + flags: vesselFlags?.size, activityQuantity: data.timeseries.reduce((acc, group) => acc + group.value, 0), activityUnit: `${eventsDataview?.datasets?.[0]?.subcategory?.toLowerCase()} ${t( 'common.events', diff --git a/apps/fishing-map/features/reports/events/vgr-events.selectors.ts b/apps/fishing-map/features/reports/events/vgr-events.selectors.ts index 3b087978fe..092dba4be8 100644 --- a/apps/fishing-map/features/reports/events/vgr-events.selectors.ts +++ b/apps/fishing-map/features/reports/events/vgr-events.selectors.ts @@ -19,8 +19,10 @@ import { getVesselsFiltered } from 'features/reports/areas/area-reports.utils' import { REPORT_FILTER_PROPERTIES } from 'features/reports/vessel-groups/vessels/vessel-group-report-vessels.selectors' import { selectVGREventsSubsectionDataview } from 'features/reports/vessel-groups/vessel-group-report.selectors' import { OTHER_CATEGORY_LABEL } from 'features/reports/vessel-groups/vessel-group-report.config' -import { formatInfoField } from 'utils/info' +import { EMPTY_FIELD_PLACEHOLDER, formatInfoField } from 'utils/info' import { MAX_CATEGORIES } from 'features/reports/areas/area-reports.config' +import { t } from 'features/i18n/i18n' +import { getVesselsWithoutDuplicates } from 'features/vessel-groups/vessel-groups.utils' export const selectFetchVGREventsVesselsParams = createSelector( [selectTimeRange, selectReportVesselGroupId, selectVGREventsSubsectionDataview], @@ -53,7 +55,8 @@ export const selectVGREventsVessels = createSelector( if (!data || !vesselGroup) { return } - const insightVessels = vesselGroup?.vessels?.flatMap((vessel) => { + const vesselsWithoutDuplicates = getVesselsWithoutDuplicates(vesselGroup.vessels) + const insightVessels = vesselsWithoutDuplicates?.flatMap((vessel) => { const vesselWithEvents = data?.find((v) => v.vesselId === vessel.vesselId) if (!vesselWithEvents) { return [] @@ -68,7 +71,7 @@ export const selectVGREventsVessels = createSelector( .sort() .map((g) => formatInfoField(g, 'geartypes')) .join(', ') || OTHER_CATEGORY_LABEL, - flagTranslated: formatInfoField(identity.flag, 'flag'), + flagTranslated: t(`flags:${identity.flag as string}` as any), } }) return insightVessels.sort((a, b) => b.numEvents - a.numEvents) @@ -90,39 +93,69 @@ export const selectVGREventsVesselsPaginated = createSelector( return vessels.slice(resultsPerPage * page, resultsPerPage * (page + 1)) } ) +type GraphDataGroup = { + name: string + value: number +} export const selectVGREventsVesselsGrouped = createSelector( [selectVGREventsVesselsFiltered, selectVGREventsVesselsProperty], (vessels, property) => { if (!vessels?.length) return [] - const groups: { name: string; value: number }[] = Object.entries( + const orderedGroups: { name: string; value: number }[] = Object.entries( groupBy(vessels, (vessel) => { - return property === 'flag' ? (vessel.flagTranslated as string) : (vessel.geartype as string) + return property === 'flag' ? vessel.flagTranslated : (vessel.geartype as string) }) ) .map(([key, value]) => ({ name: key, property: key, value: value.length })) .sort((a, b) => b.value - a.value) - if (groups.length <= MAX_CATEGORIES) { - return groups + const groupsWithoutOther: GraphDataGroup[] = [] + const otherGroups: GraphDataGroup[] = [] + orderedGroups.forEach((group) => { + if ( + group.name === 'null' || + group.name.toLowerCase() === OTHER_CATEGORY_LABEL.toLowerCase() || + group.name === EMPTY_FIELD_PLACEHOLDER + ) { + otherGroups.push(group) + } else { + groupsWithoutOther.push(group) + } + }) + const allGroups = + otherGroups.length > 0 + ? [ + ...groupsWithoutOther, + { + name: OTHER_CATEGORY_LABEL, + value: otherGroups.reduce((acc, group) => acc + group.value, 0), + }, + ] + : groupsWithoutOther + if (allGroups.length <= MAX_CATEGORIES) { + return allGroups } - - const firstNine = groups.slice(0, MAX_CATEGORIES) - const other = groups.slice(MAX_CATEGORIES) - + const firstGroups = allGroups.slice(0, MAX_CATEGORIES) + const restOfGroups = allGroups.slice(MAX_CATEGORIES) return [ - ...firstNine, + ...firstGroups, { name: OTHER_CATEGORY_LABEL, - property: other.map((g) => g.name).join(', '), - value: other.reduce((acc, group) => acc + group.value, 0), + value: restOfGroups.reduce((acc, group) => acc + group.value, 0), }, - ] + ] as GraphDataGroup[] } ) export const selectVGREventsVesselsFlags = createSelector([selectVGREventsVessels], (vessels) => { - if (!vessels?.length) return [] - return Object.keys(groupBy(vessels, (v) => v.flag)).length + if (!vessels?.length) return null + let flags = new Set() + vessels.forEach((vessel) => { + if (vessel.flagTranslated && vessel.flagTranslated !== 'null') { + flags.add(vessel.flagTranslated as string) + } + }) + return flags }) export const selectVGREventsVesselsPagination = createSelector( diff --git a/apps/fishing-map/features/reports/vessel-groups/VesselGroupReportTitle.tsx b/apps/fishing-map/features/reports/vessel-groups/VesselGroupReportTitle.tsx index 805eeb26a0..ac2248447d 100644 --- a/apps/fishing-map/features/reports/vessel-groups/VesselGroupReportTitle.tsx +++ b/apps/fishing-map/features/reports/vessel-groups/VesselGroupReportTitle.tsx @@ -14,7 +14,6 @@ import { setVesselGroupModalVessels, setVesselGroupsModalOpen, } from 'features/vessel-groups/vessel-groups-modal.slice' -import { formatInfoField } from 'utils/info' import { useLocationConnect } from 'routes/routes.hook' import { selectHasOtherVesselGroupDataviews } from 'features/dataviews/selectors/dataviews.selectors' import { @@ -23,6 +22,8 @@ import { selectVGRVesselsTimeRange, } from 'features/reports/vessel-groups/vessels/vessel-group-report-vessels.selectors' import { formatI18nDate } from 'features/i18n/i18nDate' +import { formatI18nNumber } from 'features/i18n/i18nNumber' +import { getVesselGroupVesselsCount } from 'features/vessel-groups/vessel-groups.utils' import styles from './VesselGroupReportTitle.module.css' import { VesselGroupReport } from './vessel-group-report.slice' import { selectViewOnlyVesselGroup } from './vessel-group.config.selectors' @@ -94,7 +95,7 @@ export default function VesselGroupReportTitle({ vesselGroup, loading }: ReportT t('vesselGroup.summary', { defaultValue: '{{vessels}} vessels from {{flags}} flags active from {{start}} to {{end}}', - vessels: vessels?.length, + vessels: formatI18nNumber(getVesselGroupVesselsCount(vesselGroup)), flags: flags?.size, start: formatI18nDate(timeRange.start, { format: DateTime.DATE_MED, diff --git a/apps/fishing-map/features/reports/vessel-groups/insights/vessel-group-report-insights.selectors.ts b/apps/fishing-map/features/reports/vessel-groups/insights/vessel-group-report-insights.selectors.ts index 1f55db3344..6b6e88c234 100644 --- a/apps/fishing-map/features/reports/vessel-groups/insights/vessel-group-report-insights.selectors.ts +++ b/apps/fishing-map/features/reports/vessel-groups/insights/vessel-group-report-insights.selectors.ts @@ -12,8 +12,9 @@ import { VesselGroupInsight, VesselGroupInsightResponse, } from '@globalfishingwatch/api-types' -import { getSearchIdentityResolved, getVesselId } from 'features/vessel/vessel.utils' +import { getSearchIdentityResolved } from 'features/vessel/vessel.utils' import { VesselLastIdentity } from 'features/search/search.slice' +import { getVesselsWithoutDuplicates } from 'features/vessel-groups/vessel-groups.utils' import { selectVGRFishingInsightData, selectVGRFlagChangeInsightData, @@ -36,7 +37,8 @@ export const selectVGRVesselsByInsight = ( if (!data || !vesselGroup) { return [] } - const insightVessels = vesselGroup?.vessels?.flatMap((vessel) => { + const vesselsWithoutDuplicates = getVesselsWithoutDuplicates(vesselGroup.vessels) + const insightVessels = vesselsWithoutDuplicates.flatMap((vessel) => { const vesselWithInsight = data?.[insightProperty]?.find((v) => v.vesselId === vessel.vesselId) if (!vesselWithInsight || (insightCounter && get(vesselWithInsight, insightCounter) === 0)) { return [] diff --git a/apps/fishing-map/features/reports/vessel-groups/vessels/VesselGroupReportVesselsTableFooter.tsx b/apps/fishing-map/features/reports/vessel-groups/vessels/VesselGroupReportVesselsTableFooter.tsx index 379156ec3c..ee4c45f3eb 100644 --- a/apps/fishing-map/features/reports/vessel-groups/vessels/VesselGroupReportVesselsTableFooter.tsx +++ b/apps/fishing-map/features/reports/vessel-groups/vessels/VesselGroupReportVesselsTableFooter.tsx @@ -9,10 +9,9 @@ import I18nNumber from 'features/i18n/i18nNumber' import { useLocationConnect } from 'routes/routes.hook' import { selectTimeRange } from 'features/app/selectors/app.timebar.selectors' import { REPORT_SHOW_MORE_VESSELS_PER_PAGE, REPORT_VESSELS_PER_PAGE } from 'data/config' -import { TrackCategory, trackEvent } from 'features/app/analytics.hooks' import { selectVGRData } from 'features/reports/vessel-groups/vessel-group-report.slice' -import { formatInfoField } from 'utils/info' import { selectVGRVesselFilter } from 'features/reports/vessel-groups/vessel-group.config.selectors' +import { getVesselProperty } from 'features/vessel/vessel.utils' import styles from './VesselGroupReportVesselsTableFooter.module.css' import { selectVGRVesselsFiltered, @@ -32,20 +31,17 @@ export default function VesselGroupReportVesselsTableFooter() { const onDownloadVesselsClick = () => { const vessels = allVessels?.map((vessel) => { - const vesselRegistryInfo = !!vessel.identity?.registryInfo?.length - ? vessel.identity?.registryInfo[0] - : null return { dataset: vessel.dataset, - flag: vesselRegistryInfo?.flag, + flag: getVesselProperty(vessel.identity!, 'flag'), 'flag translated': vessel.flagTranslated, 'GFW vessel type': vessel.vesselType, 'GFW gear type': vessel.geartype, sources: vessel.source, name: vessel.shipName, - MMSI: vesselRegistryInfo?.ssvid, - IMO: vesselRegistryInfo?.imo, - 'call sign': vesselRegistryInfo?.callsign, + MMSI: getVesselProperty(vessel.identity!, 'ssvid'), + IMO: getVesselProperty(vessel.identity!, 'imo'), + 'call sign': getVesselProperty(vessel.identity!, 'callsign'), vesselId: vessel.vesselId, } }) diff --git a/apps/fishing-map/features/reports/vessel-groups/vessels/vessel-group-report-vessels.selectors.ts b/apps/fishing-map/features/reports/vessel-groups/vessels/vessel-group-report-vessels.selectors.ts index 41e7414321..099253d2b3 100644 --- a/apps/fishing-map/features/reports/vessel-groups/vessels/vessel-group-report-vessels.selectors.ts +++ b/apps/fishing-map/features/reports/vessel-groups/vessels/vessel-group-report-vessels.selectors.ts @@ -111,7 +111,7 @@ export const selectVGRVesselsFlags = createSelector([selectVGRVesselsParsed], (v if (!vessels?.length) return null let flags = new Set() vessels.forEach((vessel) => { - if (vessel.flagTranslated) { + if (vessel.flagTranslated && vessel.flagTranslated !== 'null') { flags.add(vessel.flagTranslated) } }) diff --git a/apps/fishing-map/features/search/SearchActions.tsx b/apps/fishing-map/features/search/SearchActions.tsx index d7eb40f9ef..bb1b50ee36 100644 --- a/apps/fishing-map/features/search/SearchActions.tsx +++ b/apps/fishing-map/features/search/SearchActions.tsx @@ -84,7 +84,11 @@ function SearchActions() { return ( - + void + keepOpenWhileAdding?: boolean } type VesselGroupAddButtonToggleProps = { @@ -84,6 +85,7 @@ function VesselGroupAddButton(props: VesselGroupAddButtonProps) { datasetsToResolve, onAddToVesselGroup, children = , + keepOpenWhileAdding, } = props const addVesselsToVesselGroup = useVesselGroupsUpdate() const createVesselGroupWithVessels = useVesselGroupsModal() @@ -138,6 +140,7 @@ function VesselGroupAddButton(props: VesselGroupAddButtonProps) { onAddToVesselGroup={handleAddToVesselGroupClick} vessels={vesselGroupVessels} children={children} + keepOpenWhileAdding={keepOpenWhileAdding} /> ) } diff --git a/apps/fishing-map/features/vessel-groups/VesselGroupListTooltip.tsx b/apps/fishing-map/features/vessel-groups/VesselGroupListTooltip.tsx index 2136bf3e9e..5c8013c1f5 100644 --- a/apps/fishing-map/features/vessel-groups/VesselGroupListTooltip.tsx +++ b/apps/fishing-map/features/vessel-groups/VesselGroupListTooltip.tsx @@ -1,14 +1,16 @@ -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import cx from 'classnames' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' import React from 'react' +import { toast } from 'react-toastify' import { Popover, Spinner } from '@globalfishingwatch/ui-components' import { NEW_VESSEL_GROUP_ID, useVesselGroupsOptions, } from 'features/vessel-groups/vessel-groups.hooks' import { selectHasUserGroupsPermissions } from 'features/user/selectors/user.permissions.selectors' +import { selectVesselGroupsStatusId } from 'features/vessel-groups/vessel-groups.slice' import styles from './VesselGroupListTooltip.module.css' import { VesselGroupVesselIdentity } from './vessel-groups-modal.slice' @@ -16,27 +18,44 @@ type VesselGroupListTooltipProps = { children?: React.ReactNode vessels?: VesselGroupVesselIdentity[] onAddToVesselGroup?: (vesselGroupId: string) => void + keepOpenWhileAdding?: boolean } function VesselGroupListTooltip(props: VesselGroupListTooltipProps) { - const { onAddToVesselGroup, children } = props + const { onAddToVesselGroup, children, keepOpenWhileAdding = false } = props const { t } = useTranslation() const hasUserGroupsPermissions = useSelector(selectHasUserGroupsPermissions) const vesselGroupOptions = useVesselGroupsOptions() + const vesselGroupsStatusId = useSelector(selectVesselGroupsStatusId) + const [addingToGroup, setAddingToGroup] = useState(false) const [vesselGroupsOpen, setVesselGroupsOpen] = useState(false) const toggleVesselGroupsOpen = useCallback(() => { setVesselGroupsOpen(!vesselGroupsOpen) }, [vesselGroupsOpen]) + useEffect(() => { + if (addingToGroup && !vesselGroupsStatusId) { + toast(t('vesselGroup.vesselAddedToGroup', 'Your vessel group was updated'), { + toastId: 'vesselAddedToGroup', + }) + setVesselGroupsOpen(false) + setAddingToGroup(false) + } + }, [vesselGroupsStatusId, t, addingToGroup]) + const handleVesselGroupClick = useCallback( (vesselGroupId: string) => { if (onAddToVesselGroup) { onAddToVesselGroup(vesselGroupId) - setVesselGroupsOpen(false) + if (keepOpenWhileAdding) { + setAddingToGroup(true) + } else { + setVesselGroupsOpen(false) + } } }, - [onAddToVesselGroup] + [keepOpenWhileAdding, onAddToVesselGroup] ) return ( diff --git a/apps/fishing-map/features/vessel-groups/VesselGroupModal.module.css b/apps/fishing-map/features/vessel-groups/VesselGroupModal.module.css index 3faa080496..b0d69363e7 100644 --- a/apps/fishing-map/features/vessel-groups/VesselGroupModal.module.css +++ b/apps/fishing-map/features/vessel-groups/VesselGroupModal.module.css @@ -26,7 +26,6 @@ .parameters { display: flex; - height: 80px; margin-bottom: 2rem; } @@ -60,6 +59,7 @@ .idsArea { display: flex; flex-direction: column; + margin-top: var(--space-S); } .idsArea textarea { @@ -86,17 +86,25 @@ } .vesselsTableContainer { - max-height: 340px; - overflow: hidden scroll; + max-height: 400px; + max-width: 100%; + overflow: auto scroll; + border: var(--border); } .vesselsTable { width: 100%; - margin-right: var(--space-S); - margin-bottom: var(--space-XS); border-collapse: collapse; } +.vesselsTable thead { + position: sticky; + top: 0; + background-color: var(--color-white); + box-shadow: var(--box-shadow); + z-index: 1; +} + .vesselsTable tr { min-height: 10rem; } @@ -113,10 +121,10 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - padding: 0; + padding: 0 0 0 var(--space-S); line-height: 2rem; - height: 3rem; - max-width: 14rem; + height: 3.2rem; + max-width: 20rem; vertical-align: middle; } @@ -129,7 +137,8 @@ } .vesselsTable td.icon { - width: 3rem; + padding-right: var(--space-S); + width: 2rem; overflow: visible; } @@ -137,10 +146,6 @@ margin-right: 1rem; } -.new { - background-color: var(--color-help-yellow); -} - .openModalLink { text-decoration: underline; } diff --git a/apps/fishing-map/features/vessel-groups/VesselGroupModal.tsx b/apps/fishing-map/features/vessel-groups/VesselGroupModal.tsx index 01d5e91ca9..1172204f70 100644 --- a/apps/fishing-map/features/vessel-groups/VesselGroupModal.tsx +++ b/apps/fishing-map/features/vessel-groups/VesselGroupModal.tsx @@ -5,11 +5,11 @@ import { VesselGroup, VesselGroupVessel } from '@globalfishingwatch/api-types' import { Modal, Button, - Select, SelectOption, InputText, SwitchRow, Spinner, + MultiSelect, } from '@globalfishingwatch/ui-components' import { ROOT_DOM_ELEMENT } from 'data/config' import VesselGroupSearch from 'features/vessel-groups/VesselGroupModalSearch' @@ -33,7 +33,6 @@ import { resetSidebarScroll } from 'features/sidebar/sidebar.utils' import { selectSearchQuery } from 'features/search/search.config.selectors' import { TrackCategory, trackEvent } from 'features/app/analytics.hooks' import UserGuideLink from 'features/help/UserGuideLink' -import { ID_COLUMNS_OPTIONS } from 'features/vessel-groups/vessel-groups.config' import { selectVesselsDataviews } from 'features/dataviews/selectors/dataviews.instances.selectors' import { getVesselGroupDataviewInstance } from 'features/reports/vessel-groups/vessel-group-report.dataviews' import { selectIsVesselGroupReportLocation } from 'routes/routes.selectors' @@ -41,6 +40,9 @@ import { fetchVesselGroupReportThunk, resetVesselGroupReportData, } from 'features/reports/vessel-groups/vessel-group-report.slice' +import { getPlaceholderBySelections } from 'features/i18n/utils' +import { selectVesselGroupCompatibleDatasets } from 'features/datasets/datasets.selectors' +import { getDatasetLabel } from 'features/datasets/datasets.utils' import { IdField, createVesselGroupThunk, @@ -65,7 +67,6 @@ import { selectVesselGroupSearchStatus, selectVesselGroupsModalSearchIds, setVesselGroupModalVessels, - setVesselGroupSearchIdField, } from './vessel-groups-modal.slice' import { getVesselGroupUniqVessels, getVesselGroupVesselsCount } from './vessel-groups.utils' @@ -108,10 +109,35 @@ function VesselGroupModal(): React.ReactElement { ? vesselGroupVesselsToSearch?.length < MAX_VESSEL_GROUP_VESSELS : true + const vesselDatasets = useSelector(selectVesselGroupCompatibleDatasets) + const sourceOptions = vesselDatasets.map((d) => ({ + id: d.id, + label: getDatasetLabel(d), + })) + const [sourcesSelected, setSourcesSelected] = useState([sourceOptions[0]]) + + const onSelectSourceClick = useCallback( + (source: SelectOption) => { + setSourcesSelected([...sourcesSelected, source]) + }, + [sourcesSelected] + ) + + const onRemoveSourceClick = useCallback( + (source: SelectOption) => { + setSourcesSelected(sourcesSelected.filter((s) => s.id !== source.id)) + }, + [sourcesSelected] + ) + const dispatchSearchVesselsGroupsThunk = useCallback( async (ids: string[], idField: IdField = 'vesselId') => { searchVesselGroupsVesselsRef.current = dispatch( - searchVesselGroupsVesselsThunk({ ids, idField }) + searchVesselGroupsVesselsThunk({ + ids, + idField, + datasets: sourcesSelected.map(({ id }) => id), + }) ) const action = await searchVesselGroupsVesselsRef.current if (searchVesselGroupsVesselsThunk.fulfilled.match(action)) { @@ -120,7 +146,7 @@ function VesselGroupModal(): React.ReactElement { setError((action.payload as any)?.message || '') } }, - [dispatch] + [dispatch, sourcesSelected] ) useEffect(() => { @@ -133,13 +159,6 @@ function VesselGroupModal(): React.ReactElement { setGroupName(e.target.value) }, []) - const onIdFieldChange = useCallback( - (option: SelectOption) => { - dispatch(setVesselGroupSearchIdField(option.id)) - }, - [dispatch] - ) - const abortSearch = useCallback(() => { if (searchVesselGroupsVesselsRef.current?.abort) { searchVesselGroupsVesselsRef.current.abort() @@ -322,13 +341,16 @@ function VesselGroupModal(): React.ReactElement { {!fullModalLoading && searchVesselStatus !== AsyncReducerStatus.Error && !hasVesselGroupsVessels && ( - o.id === searchIdField)} - onSelect={onIdFieldChange} - disabled={hasVesselGroupsVessels} + id), + options: sourceOptions, + })} + options={sourceOptions} + selectedOptions={sourcesSelected} + onSelect={onSelectSourceClick} + onRemove={sourcesSelected?.length > 1 ? onRemoveSourceClick : undefined} /> )} diff --git a/apps/fishing-map/features/vessel-groups/VesselGroupModalSearch.tsx b/apps/fishing-map/features/vessel-groups/VesselGroupModalSearch.tsx index e2a917dba0..4b66e72654 100644 --- a/apps/fishing-map/features/vessel-groups/VesselGroupModalSearch.tsx +++ b/apps/fishing-map/features/vessel-groups/VesselGroupModalSearch.tsx @@ -3,11 +3,12 @@ import { useTranslation } from 'react-i18next' import { parse as parseCSV } from 'papaparse' import { useSelector } from 'react-redux' import { useDebounce } from '@globalfishingwatch/react-hooks' -import { TextArea } from '@globalfishingwatch/ui-components' +import { Select, SelectOption, TextArea } from '@globalfishingwatch/ui-components' import FileDropzone from 'features/datasets/upload/FileDropzone' import { readBlobAs } from 'utils/files' import { useAppDispatch } from 'features/app/app.hooks' -import { ID_COLUMN_LOOKUP } from 'features/vessel-groups/vessel-groups.config' +import { ID_COLUMN_LOOKUP, ID_COLUMNS_OPTIONS } from 'features/vessel-groups/vessel-groups.config' +import { selectHasVesselGroupSearchVessels } from 'features/vessel-groups/vessel-groups.selectors' import { selectVesselGroupModalSearchIdField, selectVesselGroupsModalSearchIds, @@ -25,6 +26,7 @@ function VesselGroupSearch({ onError }: { onError: (string: any) => void }) { const vesselGroupVesselsToSearch = useSelector(selectVesselGroupsModalSearchIds) const hasGroupVesselsToSearch = vesselGroupVesselsToSearch && vesselGroupVesselsToSearch.length > 0 + const hasVesselGroupsVessels = useSelector(selectHasVesselGroupSearchVessels) useEffect(() => { if (debouncedSearchText) { @@ -80,9 +82,24 @@ function VesselGroupSearch({ onError }: { onError: (string: any) => void }) { }, [dispatch, onError, t] ) + + const onIdFieldChange = useCallback( + (option: SelectOption) => { + dispatch(setVesselGroupSearchIdField(option.id)) + }, + [dispatch] + ) + return ( + o.id === searchIdField)} + onSelect={onIdFieldChange} + disabled={hasVesselGroupsVessels} + /> - {ssvid} + {ssvid || EMPTY_FIELD_PLACEHOLDER} + {imo || EMPTY_FIELD_PLACEHOLDER} {vesselName} {flag ? t(`flags:${flag as string}` as any) : EMPTY_FIELD_PLACEHOLDER} @@ -65,7 +66,7 @@ function VesselGroupVesselRow({ )} - + {t('vessel.mmsi', 'MMSI')} + {t('vessel.imo', 'IMO')} {t('common.name', 'Name')} {t('vessel.flag', 'flag')} {t('vessel.gearType_short', 'gear')} @@ -125,7 +127,6 @@ function VesselGroupVessels() { return ( onVesselRemoveClick(vessel)} /> diff --git a/apps/fishing-map/features/vessel-groups/vessel-groups-modal.slice.ts b/apps/fishing-map/features/vessel-groups/vessel-groups-modal.slice.ts index 5f178a2e66..b17bd40c43 100644 --- a/apps/fishing-map/features/vessel-groups/vessel-groups-modal.slice.ts +++ b/apps/fishing-map/features/vessel-groups/vessel-groups-modal.slice.ts @@ -21,7 +21,7 @@ import { } from '@globalfishingwatch/api-client' import { resolveEndpoint } from '@globalfishingwatch/datasets-client' import { runDatasetMigrations } from '@globalfishingwatch/dataviews-client' -import { selectVesselsDatasets } from 'features/datasets/datasets.selectors' +import { selectVesselGroupCompatibleDatasets } from 'features/datasets/datasets.selectors' import { AsyncReducerStatus } from 'utils/async-slice' import { INCLUDES_RELATED_SELF_REPORTED_INFO_ID } from 'features/vessel/vessel.config' import { IdField } from 'features/vessel-groups/vessel-groups.slice' @@ -212,13 +212,8 @@ export const searchVesselGroupsVesselsThunk = createAsyncThunk( ) => { try { const state = getState() as any - const searchDatasets = (selectVesselsDatasets(state) || []).filter((d) => { - const matchesDataset = datasets?.length ? datasets.includes(d.id) : true - return ( - matchesDataset && - d.status !== DatasetStatus.Deleted && - d.configuration?.apiSupportedVersions?.includes('v3') - ) + const searchDatasets = (selectVesselGroupCompatibleDatasets(state) || []).filter((d) => { + return datasets?.length ? datasets.includes(d.id) : true /*&& d.alias?.some((alias) => alias.includes(':latest'))*/ }) const vesselGroupVessels = await searchVesselsInVesselGroup({ diff --git a/apps/fishing-map/features/vessel-groups/vessel-groups.config.ts b/apps/fishing-map/features/vessel-groups/vessel-groups.config.ts index 2e9727edc4..4c850ecba8 100644 --- a/apps/fishing-map/features/vessel-groups/vessel-groups.config.ts +++ b/apps/fishing-map/features/vessel-groups/vessel-groups.config.ts @@ -11,3 +11,4 @@ export const ID_COLUMNS_OPTIONS: SelectOption[] = ID_COLUMN_LOOKUP.map((key) => // TODO:VV3 set the proper date export const VESSEL_GROUPS_REPORT_RELEASE_DATE = '2024-09-30' +export const VESSEL_GROUPS_MIN_API_VERSION = 'v3' diff --git a/apps/fishing-map/features/vessel-groups/vessel-groups.slice.ts b/apps/fishing-map/features/vessel-groups/vessel-groups.slice.ts index 215ffbf66b..04a92b3a92 100644 --- a/apps/fishing-map/features/vessel-groups/vessel-groups.slice.ts +++ b/apps/fishing-map/features/vessel-groups/vessel-groups.slice.ts @@ -115,23 +115,21 @@ export const createVesselGroupThunk = createAsyncThunk( } const saveVesselGroup: any = async (vesselGroup: VesselGroupUpsert, tries = 0) => { let vesselGroupUpdated: VesselGroup - if (tries < 5) { - try { - const name = tries > 0 ? vesselGroupUpsert.name + `_${tries}` : vesselGroupUpsert.name - vesselGroupUpdated = await GFWAPI.fetch('/vessel-groups', { - method: 'POST', - body: { ...vesselGroup, name }, - } as FetchOptions) - } catch (e: any) { - // Means we already have a workspace with this name - if (e.status === 422 && e.message.includes('Id') && e.message.includes('duplicated')) { - return await saveVesselGroup(vesselGroup, tries + 1) - } - console.warn('Error creating vessel group', e) - throw e + try { + const name = tries > 0 ? vesselGroupUpsert.name + `_${tries}` : vesselGroupUpsert.name + vesselGroupUpdated = await GFWAPI.fetch('/vessel-groups', { + method: 'POST', + body: { ...vesselGroup, name }, + } as FetchOptions) + } catch (e: any) { + // Means we already have a workspace with this name + if (e.status === 422 && e.message.includes('Id') && e.message.includes('duplicated')) { + return await saveVesselGroup(vesselGroup, Date.now()) } - return vesselGroupUpdated + console.warn('Error creating vessel group', e) + throw e } + return vesselGroupUpdated } return await saveVesselGroup(vesselGroupUpsert) } diff --git a/apps/fishing-map/features/vessel-groups/vessel-groups.utils.ts b/apps/fishing-map/features/vessel-groups/vessel-groups.utils.ts index fbec7d69fb..b30bf48c45 100644 --- a/apps/fishing-map/features/vessel-groups/vessel-groups.utils.ts +++ b/apps/fishing-map/features/vessel-groups/vessel-groups.utils.ts @@ -119,23 +119,33 @@ export const flatVesselGroupSearchVessels = ( export function parseVesselGroupVessels( vessels: AddVesselGroupVessel[] ): VesselGroupVesselIdentity[] { - return vessels?.map((vessel) => { + return vessels?.flatMap((vessel) => { if ((vessel as IdentityVesselData).identities?.length) { const identityVessel = vessel as IdentityVesselData - const relationId = identityVessel.id - return { - vesselId: identityVessel.id, - dataset: identityVessel.datasetId || (identityVessel.dataset?.id as string), - relationId: relationId, - identity: - relationId === identityVessel.id - ? { - dataset: identityVessel.datasetId || identityVessel.dataset?.id, - selfReportedInfo: identityVessel.identities, - } - : undefined, - } as VesselGroupVesselIdentity + const selfReportedIdentities = identityVessel.identities?.filter( + (i) => i.identitySource === VesselIdentitySourceEnum.SelfReported + ) + if (selfReportedIdentities?.length) { + const relationId = identityVessel.id + return { + vesselId: identityVessel.id, + dataset: identityVessel.datasetId || (identityVessel.dataset?.id as string), + relationId: relationId, + identity: + relationId === identityVessel.id + ? { + dataset: identityVessel.datasetId || identityVessel.dataset?.id, + selfReportedInfo: selfReportedIdentities, + } + : undefined, + } as VesselGroupVesselIdentity + } + return [] } return vessel as VesselGroupVesselIdentity }) } + +export const getVesselsWithoutDuplicates = (vessels: VesselGroupVesselIdentity[]) => { + return vessels.filter((v) => v.identity !== undefined) +} diff --git a/apps/fishing-map/public/locales/source/translations.json b/apps/fishing-map/public/locales/source/translations.json index 31da3c8228..814eff78b3 100644 --- a/apps/fishing-map/public/locales/source/translations.json +++ b/apps/fishing-map/public/locales/source/translations.json @@ -1045,6 +1045,7 @@ "tooManyVessels_other": "Maximum number of vessels is {{count}}", "updateRequired": "Update required", "uploadPublic": "Allow other users to see this vessel group when you share a workspace", + "vesselAddedToGroup": "Your vessel group was updated", "vesselGroup": "Vessel group", "vesselGroups": "Vessel groups" },