diff --git a/apps/fishing-map/features/dataviews/dataviews.utils.ts b/apps/fishing-map/features/dataviews/dataviews.utils.ts index dbcc8300b8..4390951f41 100644 --- a/apps/fishing-map/features/dataviews/dataviews.utils.ts +++ b/apps/fishing-map/features/dataviews/dataviews.utils.ts @@ -25,6 +25,7 @@ import { getActiveDatasetsInDataview, isPrivateDataset, } from 'features/datasets/datasets.utils' +import { INCLUDES_RELATED_SELF_REPORTED_INFO_ID } from 'features/vessel/vessel.config' // used in workspaces with encounter events layers export const ENCOUNTER_EVENTS_SOURCE_ID = 'encounter' @@ -57,7 +58,7 @@ export const getVesselInfoDataviewInstanceDatasetConfig = ( { id: 'dataset', value: info }, { id: 'includes', - value: ['POTENTIAL_RELATED_SELF_REPORTED_INFO'], + value: [INCLUDES_RELATED_SELF_REPORTED_INFO_ID], }, ], endpoint: EndpointId.Vessel, diff --git a/apps/fishing-map/features/map/map.slice.ts b/apps/fishing-map/features/map/map.slice.ts index a1b34596ba..7ff4896177 100644 --- a/apps/fishing-map/features/map/map.slice.ts +++ b/apps/fishing-map/features/map/map.slice.ts @@ -47,6 +47,7 @@ import { selectEventsDataviews, selectVesselGroupDataviews, } from 'features/dataviews/selectors/dataviews.categories.selectors' +import { INCLUDES_RELATED_SELF_REPORTED_INFO_ID } from 'features/vessel/vessel.config' export const MAX_TOOLTIP_LIST = 5 @@ -197,7 +198,7 @@ const getVesselInfoEndpoint = (vesselDatasets: Dataset[], vesselIds: string[]) = }, { id: 'includes', - value: ['POTENTIAL_RELATED_SELF_REPORTED_INFO'], + value: [INCLUDES_RELATED_SELF_REPORTED_INFO_ID], }, ], } diff --git a/apps/fishing-map/features/modals/Modals.tsx b/apps/fishing-map/features/modals/Modals.tsx index 86c2ee2501..8502f340ff 100644 --- a/apps/fishing-map/features/modals/Modals.tsx +++ b/apps/fishing-map/features/modals/Modals.tsx @@ -12,7 +12,7 @@ import { ROOT_DOM_ELEMENT } from 'data/config' import useSecretMenu, { useSecretKeyboardCombo } from 'hooks/secret-menu.hooks' import { selectBigQueryActive, toggleBigQueryMenu } from 'features/bigquery/bigquery.slice' import { selectDownloadActivityAreaKey } from 'features/download/downloadActivity.slice' -import { selectVesselGroupModalOpen } from 'features/vessel-groups/vessel-groups.slice' +import { selectVesselGroupModalOpen } from 'features/vessel-groups/vessel-groups-modal.slice' import GFWOnly from 'features/user/GFWOnly' import { useAppDispatch } from 'features/app/app.hooks' import { selectDatasetUploadModalOpen, setModalOpen } from 'features/modals/modals.slice' diff --git a/apps/fishing-map/features/modals/modals.selectors.ts b/apps/fishing-map/features/modals/modals.selectors.ts index 2584b1ba59..9333e744d7 100644 --- a/apps/fishing-map/features/modals/modals.selectors.ts +++ b/apps/fishing-map/features/modals/modals.selectors.ts @@ -10,7 +10,7 @@ import { selectLayerLibraryModalOpen, selectScreenshotModalOpen, } from 'features/modals/modals.slice' -import { selectVesselGroupModalOpen } from 'features/vessel-groups/vessel-groups.slice' +import { selectVesselGroupModalOpen } from 'features/vessel-groups/vessel-groups-modal.slice' import { WelcomeContentKey } from 'features/welcome/welcome.content' import { selectLocationCategory, diff --git a/apps/fishing-map/features/reports/activity/vessels/ReportVesselsTableFooter.tsx b/apps/fishing-map/features/reports/activity/vessels/ReportVesselsTableFooter.tsx index f3bf986ed7..52da554920 100644 --- a/apps/fishing-map/features/reports/activity/vessels/ReportVesselsTableFooter.tsx +++ b/apps/fishing-map/features/reports/activity/vessels/ReportVesselsTableFooter.tsx @@ -4,6 +4,7 @@ import cx from 'classnames' import { Fragment, useMemo } from 'react' import { unparse as unparseCSV } from 'papaparse' import { saveAs } from 'file-saver' +import { uniq } from 'es-toolkit' import { Button, IconButton } from '@globalfishingwatch/ui-components' import I18nNumber from 'features/i18n/i18nNumber' import { useLocationConnect } from 'routes/routes.hook' @@ -11,10 +12,7 @@ import VesselGroupAddButton from 'features/vessel-groups/VesselGroupAddButton' import { selectTimeRange } from 'features/app/selectors/app.timebar.selectors' import { REPORT_SHOW_MORE_VESSELS_PER_PAGE, REPORT_VESSELS_PER_PAGE } from 'data/config' import { useAppDispatch } from 'features/app/app.hooks' -import { - setVesselGroupConfirmationMode, - setVesselGroupCurrentDataviewIds, -} from 'features/vessel-groups/vessel-groups.slice' +import { setVesselGroupConfirmationMode } from 'features/vessel-groups/vessel-groups-modal.slice' import { selectActiveActivityAndDetectionsDataviews } from 'features/dataviews/selectors/dataviews.selectors' import { TrackCategory, trackEvent } from 'features/app/analytics.hooks' import { selectReportVesselFilter } from 'features/reports/areas/area-reports.config.selectors' @@ -22,10 +20,7 @@ import { selectReportAreaName, ReportVesselWithDatasets, } from 'features/reports/areas/area-reports.selectors' -import { - parseReportVesselsToIdentity, - getVesselsFiltered, -} from 'features/reports/areas/area-reports.utils' +import { getVesselsFiltered } from 'features/reports/areas/area-reports.utils' import styles from './ReportVesselsTableFooter.module.css' import { selectReportVesselsListWithAllInfo, @@ -51,8 +46,15 @@ export default function ReportVesselsTableFooter({ reportName }: ReportVesselsTa const heatmapDataviews = useSelector(selectActiveActivityAndDetectionsDataviews) const { start, end } = useSelector(selectTimeRange) - const vesselGroupIdentityVessels = useMemo(() => { - return parseReportVesselsToIdentity(reportVesselFilter ? allFilteredVessels : allVessels) + const vesselGroupVessels = useMemo(() => { + const vessels = reportVesselFilter ? allFilteredVessels : allVessels + if (!vessels?.length) { + return null + } + return { + ids: vessels?.flatMap((v) => v.id || v.vesselId || []), + datasets: uniq(vessels.flatMap((v) => v.dataset || [])), + } }, [allFilteredVessels, allVessels, reportVesselFilter]) const onDownloadVesselsClick = () => { @@ -99,11 +101,7 @@ export default function ReportVesselsTableFooter({ reportName }: ReportVesselsTa }) } const onAddToVesselGroup = () => { - const dataviewIds = heatmapDataviews.map(({ id }) => id) dispatch(setVesselGroupConfirmationMode('saveAndSeeInWorkspace')) - if (dataviewIds?.length) { - dispatch(setVesselGroupCurrentDataviewIds(dataviewIds)) - } trackEvent({ category: TrackCategory.VesselGroups, action: 'add_to_vessel_group', @@ -171,7 +169,8 @@ export default function ReportVesselsTableFooter({ reportName }: ReportVesselsTa
{vessels?.map((vessel, i) => { - const { id, shipName, flag, flagTranslatedClean, flagTranslated, mmsi, index } = vessel + const { shipName, flagTranslated, flagTranslatedClean, identity } = vessel + const { id, flag, ssvid } = getSearchIdentityResolved(identity!) const isLastRow = i === vessels.length - 1 const flagInteractionEnabled = !EMPTY_API_VALUES.includes(flagTranslated) const type = vessel.vesselType @@ -117,7 +117,7 @@ export default function VesselGroupReportVesselsTable() {
@@ -132,7 +132,7 @@ export default function VesselGroupReportVesselsTable() { )}
- {mmsi || EMPTY_FIELD_PLACEHOLDER} + {ssvid || EMPTY_FIELD_PLACEHOLDER}
{ // const dataviewIds = heatmapDataviews.map(({ id }) => id) // dispatch(setVesselGroupConfirmationMode('saveAndNavigate')) - // if (dataviewIds?.length) { - // dispatch(setVesselGroupCurrentDataviewIds(dataviewIds)) - // } // trackEvent({ // category: TrackCategory.VesselGroups, // action: 'add_to_vessel_group', 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 b21fd0c15b..9018cfb6da 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 @@ -21,6 +21,8 @@ import { selectVGRVesselsSubsection, } from 'features/reports/vessel-groups/vessel-group.config.selectors' import { cleanFlagState } from 'features/reports/activity/vessels/report-activity-vessels.utils' +import { getVesselGroupUniqVessels } from 'features/vessel-groups/vessel-groups.utils' +import { VesselGroupVesselIdentity } from 'features/vessel-groups/vessel-groups-modal.slice' import { selectVGRVessels } from '../vessel-group-report.slice' import { VesselGroupReportVesselParsed } from './vessel-group-report-vessels.types' @@ -36,24 +38,35 @@ const getVesselSource = (vessel: IdentityVessel) => { return source } -export const selectVGRVesselsParsed = createSelector([selectVGRVessels], (vessels) => { - if (!vessels?.length) return null - return vessels.map((vessel, index) => { - const { ssvid, ...vesselData } = getSearchIdentityResolved(vessel) - const source = getVesselSource(vessel) +export type VesselGroupVesselTableParsed = VesselGroupVesselIdentity & VesselGroupReportVesselParsed + +export const selectVGRUniqVessels = createSelector([selectVGRVessels], (vessels) => { + if (!vessels?.length) { + return + } + return getVesselGroupUniqVessels(vessels).filter((v) => v.identity) +}) + +export const selectVGRVesselsParsed = createSelector([selectVGRUniqVessels], (vessels) => { + if (!vessels?.length) { + return + } + return getVesselGroupUniqVessels(vessels).flatMap((vessel) => { + if (!vessel.identity) { + return [] + } + const { shipname, flag, ...vesselData } = getSearchIdentityResolved(vessel.identity!) + const source = getVesselSource(vessel.identity) return { - ...vesselData, - index: index, - shipName: formatInfoField(vesselData.shipname, 'name'), + ...vessel, + shipName: formatInfoField(shipname, 'name') as string, vesselType: getVesselShipTypeLabel(vesselData), geartype: getVesselGearTypeLabel(vesselData), - flagTranslated: t(`flags:${vesselData.flag as string}` as any), - flagTranslatedClean: cleanFlagState(t(`flags:${vesselData.flag as string}` as any)), + flagTranslated: t(`flags:${flag as string}` as any), + flagTranslatedClean: cleanFlagState(t(`flags:${flag as string}` as any)), source: t(`common.sourceOptions.${source}`, source), - mmsi: ssvid, - dataset: vessel.dataset, - } - }) as VesselGroupReportVesselParsed[] + } as VesselGroupVesselTableParsed + }) }) type ReportFilterProperty = FilterProperty | 'source' @@ -67,11 +80,12 @@ export const selectVGRVesselsTimeRange = createSelector([selectVGRVesselsParsed] let start: string = '' let end: string = '' vessels.forEach((vessel) => { - if (!start || vessel.transmissionDateFrom < start) { - start = vessel.transmissionDateFrom + const { transmissionDateFrom, transmissionDateTo } = getSearchIdentityResolved(vessel.identity!) + if (!start || transmissionDateFrom < start) { + start = transmissionDateFrom } - if (!end || vessel.transmissionDateTo > end) { - end = vessel.transmissionDateTo + if (!end || transmissionDateTo > end) { + end = transmissionDateTo } }) return { start, end } @@ -92,7 +106,7 @@ export const selectVGRVesselsFiltered = createSelector( [selectVGRVesselsParsed, selectVGRVesselFilter], (vessels, filter) => { if (!vessels?.length) return null - return getVesselsFiltered( + return getVesselsFiltered( vessels, filter, REPORT_FILTER_PROPERTIES @@ -139,7 +153,7 @@ export const selectVGRVesselsPaginated = createSelector( export const selectVGRVesselsPagination = createSelector( [ selectVGRVesselsPaginated, - selectVGRVessels, + selectVGRUniqVessels, selectVGRVesselsFiltered, selectVGRVesselPage, selectVGRVesselsResultsPerPage, diff --git a/apps/fishing-map/features/search/SearchActions.tsx b/apps/fishing-map/features/search/SearchActions.tsx index 81d163b105..d7eb40f9ef 100644 --- a/apps/fishing-map/features/search/SearchActions.tsx +++ b/apps/fishing-map/features/search/SearchActions.tsx @@ -14,10 +14,7 @@ import VesselGroupAddButton, { VesselGroupAddActionButton, } from 'features/vessel-groups/VesselGroupAddButton' import { selectActiveActivityAndDetectionsDataviews } from 'features/dataviews/selectors/dataviews.selectors' -import { - setVesselGroupConfirmationMode, - setVesselGroupCurrentDataviewIds, -} from 'features/vessel-groups/vessel-groups.slice' +import { setVesselGroupConfirmationMode } from 'features/vessel-groups/vessel-groups-modal.slice' import { HOME, WORKSPACE } from 'routes/routes' import { EMPTY_FILTERS } from 'features/search/search.config' import { getRelatedIdentityVesselIds } from 'features/vessel/vessel.utils' @@ -76,9 +73,6 @@ function SearchActions() { const onAddToVesselGroup = () => { const dataviewIds = heatmapDataviews.map(({ id }) => id) dispatch(setVesselGroupConfirmationMode('saveAndSeeInWorkspace')) - if (dataviewIds?.length) { - dispatch(setVesselGroupCurrentDataviewIds(dataviewIds)) - } trackEvent({ category: TrackCategory.SearchVessel, action: 'Click add to vessel group', diff --git a/apps/fishing-map/features/search/advanced/SearchAdvancedResults.tsx b/apps/fishing-map/features/search/advanced/SearchAdvancedResults.tsx index 47d9bffcd8..6d253c24e0 100644 --- a/apps/fishing-map/features/search/advanced/SearchAdvancedResults.tsx +++ b/apps/fishing-map/features/search/advanced/SearchAdvancedResults.tsx @@ -202,7 +202,7 @@ function SearchAdvancedResults({ fetchResults, fetchMoreResults }: SearchCompone return ( onVesselClick(e, vesselData)} query={vesselQuery} diff --git a/apps/fishing-map/features/user/UserVesselGroups.tsx b/apps/fishing-map/features/user/UserVesselGroups.tsx index 1d5c364294..7dda3da769 100644 --- a/apps/fishing-map/features/user/UserVesselGroups.tsx +++ b/apps/fishing-map/features/user/UserVesselGroups.tsx @@ -1,23 +1,29 @@ import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' import { useCallback } from 'react' -import Link from 'redux-first-router-link' import { Spinner, IconButton, Button } from '@globalfishingwatch/ui-components' import { VesselGroup } from '@globalfishingwatch/api-types' import { AsyncReducerStatus } from 'utils/async-slice' import { - setVesselGroupsModalOpen, selectVesselGroupsStatus, selectVesselGroupsStatusId, deleteVesselGroupThunk, - setVesselGroupEditId, - selectVesselGroupEditId, } from 'features/vessel-groups/vessel-groups.slice' import { useAppDispatch } from 'features/app/app.hooks' import { selectDatasetsStatus } from 'features/datasets/datasets.slice' -import { getVesselGroupLabel } from 'features/vessel-groups/vessel-groups.utils' +import { + getVesselGroupLabel, + getVesselGroupVesselsCount, + isOutdatedVesselGroup, +} from 'features/vessel-groups/vessel-groups.utils' import { sortByCreationDate } from 'utils/dates' import VesselGroupReportLink from 'features/reports/vessel-groups/VesselGroupReportLink' +import { + selectVesselGroupEditId, + setVesselGroupConfirmationMode, + setVesselGroupEditId, + setVesselGroupsModalOpen, +} from 'features/vessel-groups/vessel-groups-modal.slice' import { selectUserVesselGroups } from './selectors/user.permissions.selectors' import styles from './User.module.css' @@ -41,6 +47,7 @@ function UserVesselGroups() { async (vesselGroup: VesselGroup) => { dispatch(setVesselGroupEditId(vesselGroup.id)) dispatch(setVesselGroupsModalOpen(true)) + dispatch(setVesselGroupConfirmationMode('update')) }, [dispatch] ) @@ -76,26 +83,52 @@ function UserVesselGroups() {
    {vesselGroups && vesselGroups.length > 0 ? ( sortByCreationDate(vesselGroups).map((vesselGroup) => { + const isOutdated = isOutdatedVesselGroup(vesselGroup) return (
  • - - + {isOutdated ? ( + {getVesselGroupLabel(vesselGroup)}{' '} - ({vesselGroup.vessels.length}) - + + ({getVesselGroupVesselsCount(vesselGroup)}) + - + ) : ( + + + {getVesselGroupLabel(vesselGroup)}{' '} + + ({getVesselGroupVesselsCount(vesselGroup)}) + + + + + )}
    onEditClick(vesselGroup)} /> onDeleteClick(vesselGroup)} /> diff --git a/apps/fishing-map/features/user/selectors/user.permissions.selectors.ts b/apps/fishing-map/features/user/selectors/user.permissions.selectors.ts index 7cc148dad7..3b10b7041b 100644 --- a/apps/fishing-map/features/user/selectors/user.permissions.selectors.ts +++ b/apps/fishing-map/features/user/selectors/user.permissions.selectors.ts @@ -5,10 +5,7 @@ import { DatasetStatus, DatasetCategory, UserPermission } from '@globalfishingwa import { selectAllDatasets } from 'features/datasets/datasets.slice' import { selectWorkspaces } from 'features/workspaces-list/workspaces-list.slice' import { AUTO_GENERATED_FEEDBACK_WORKSPACE_PREFIX, PRIVATE_SUFIX, USER_SUFIX } from 'data/config' -import { - selectAllVesselGroups, - selectWorkspaceVesselGroups, -} from 'features/vessel-groups/vessel-groups.slice' +import { selectAllVesselGroups } from 'features/vessel-groups/vessel-groups.slice' import { selectAllReports } from 'features/reports/areas/area-reports.slice' import { selectUserData } from 'features/user/selectors/user.selectors' import { DEFAULT_GROUP_ID } from 'features/user/user.config' @@ -121,8 +118,8 @@ export const selectUserVesselGroups = createSelector( ) export const selectAllVisibleVesselGroups = createSelector( - [selectUserVesselGroups, selectWorkspaceVesselGroups], - (vesselGroups = [], workspaceVesselGroups = []) => { - return uniqBy([...vesselGroups, ...(workspaceVesselGroups || [])], (v) => v.id) + [selectUserVesselGroups], + (vesselGroups = []) => { + return uniqBy([...vesselGroups], (v) => v.id) } ) diff --git a/apps/fishing-map/features/vessel-groups/VesselGroupAddButton.tsx b/apps/fishing-map/features/vessel-groups/VesselGroupAddButton.tsx index 247aa11e7b..22d8a1569c 100644 --- a/apps/fishing-map/features/vessel-groups/VesselGroupAddButton.tsx +++ b/apps/fishing-map/features/vessel-groups/VesselGroupAddButton.tsx @@ -4,8 +4,12 @@ import { useTranslation } from 'react-i18next' import React from 'react' import { useSelector } from 'react-redux' import { Button, ButtonType, ButtonSize } from '@globalfishingwatch/ui-components' -import { MAX_VESSEL_GROUP_VESSELS } from 'features/vessel-groups/vessel-groups.slice' +import { + MAX_VESSEL_GROUP_VESSELS, + searchVesselGroupsVesselsThunk, +} from 'features/vessel-groups/vessel-groups-modal.slice' import { selectIsGuestUser } from 'features/user/selectors/user.selectors' +import { useAppDispatch } from 'features/app/app.hooks' import styles from './VesselGroupListTooltip.module.css' import VesselGroupListTooltip from './VesselGroupListTooltip' import { @@ -14,10 +18,13 @@ import { useVesselGroupsUpdate, NEW_VESSEL_GROUP_ID, } from './vessel-groups.hooks' +import { parseVesselGroupVessels } from './vessel-groups.utils' type VesselGroupAddButtonProps = { children?: React.ReactNode - vessels: AddVesselGroupVessel[] + vessels?: AddVesselGroupVessel[] + vesselsToResolve?: string[] + datasetsToResolve?: string[] onAddToVesselGroup?: (vesselGroupId: string) => void } @@ -69,32 +76,65 @@ export function VesselGroupAddActionButton({ } function VesselGroupAddButton(props: VesselGroupAddButtonProps) { - const { vessels, onAddToVesselGroup, children = } = props + const { + vessels, + vesselsToResolve, + datasetsToResolve, + onAddToVesselGroup, + children = , + } = props const addVesselsToVesselGroup = useVesselGroupsUpdate() const createVesselGroupWithVessels = useVesselGroupsModal() + const vesselGroupVessels = parseVesselGroupVessels(vessels!) + const dispatch = useAppDispatch() const handleAddToVesselGroupClick = useCallback( async (vesselGroupId: string) => { + let resolvedVesselGroupVessels = vesselGroupVessels + if (vesselsToResolve && datasetsToResolve) { + // TODO:VV3 check if this works properly + const action = await dispatch( + searchVesselGroupsVesselsThunk({ + ids: vesselsToResolve, + idField: 'vesselId', + datasets: datasetsToResolve, + }) + ) + if (searchVesselGroupsVesselsThunk.fulfilled.match(action)) { + resolvedVesselGroupVessels = action.payload + } + } if (vesselGroupId !== NEW_VESSEL_GROUP_ID) { - if (vessels.length) { - const vesselGroup = await addVesselsToVesselGroup(vesselGroupId, vessels) + if (resolvedVesselGroupVessels.length) { + const vesselGroup = await addVesselsToVesselGroup( + vesselGroupId, + resolvedVesselGroupVessels + ) if (onAddToVesselGroup && vesselGroup) { onAddToVesselGroup(vesselGroup?.id) } } } else { - createVesselGroupWithVessels(vesselGroupId, vessels) + createVesselGroupWithVessels(vesselGroupId, resolvedVesselGroupVessels) if (onAddToVesselGroup) { onAddToVesselGroup(vesselGroupId) } } }, - [addVesselsToVesselGroup, createVesselGroupWithVessels, onAddToVesselGroup, vessels] + [ + addVesselsToVesselGroup, + createVesselGroupWithVessels, + datasetsToResolve, + dispatch, + onAddToVesselGroup, + vesselGroupVessels, + vesselsToResolve, + ] ) return ( ) diff --git a/apps/fishing-map/features/vessel-groups/VesselGroupListTooltip.tsx b/apps/fishing-map/features/vessel-groups/VesselGroupListTooltip.tsx index ad8b1169d5..ebbb03d80c 100644 --- a/apps/fishing-map/features/vessel-groups/VesselGroupListTooltip.tsx +++ b/apps/fishing-map/features/vessel-groups/VesselGroupListTooltip.tsx @@ -5,16 +5,16 @@ import { useSelector } from 'react-redux' import React from 'react' import { Popover, Spinner } from '@globalfishingwatch/ui-components' import { - AddVesselGroupVessel, NEW_VESSEL_GROUP_ID, useVesselGroupsOptions, } from 'features/vessel-groups/vessel-groups.hooks' import { selectHasUserGroupsPermissions } from 'features/user/selectors/user.permissions.selectors' import styles from './VesselGroupListTooltip.module.css' +import { VesselGroupVesselIdentity } from './vessel-groups-modal.slice' type VesselGroupListTooltipProps = { children?: React.ReactNode - vessels?: AddVesselGroupVessel[] + vessels?: VesselGroupVesselIdentity[] onAddToVesselGroup?: (vesselGroupId: string) => void } diff --git a/apps/fishing-map/features/vessel-groups/VesselGroupModal.tsx b/apps/fishing-map/features/vessel-groups/VesselGroupModal.tsx index fa7f641a81..9053621f99 100644 --- a/apps/fishing-map/features/vessel-groups/VesselGroupModal.tsx +++ b/apps/fishing-map/features/vessel-groups/VesselGroupModal.tsx @@ -16,7 +16,6 @@ import VesselGroupSearch from 'features/vessel-groups/VesselGroupModalSearch' import VesselGroupVessels from 'features/vessel-groups/VesselGroupModalVessels' import { useAppDispatch } from 'features/app/app.hooks' import { - selectAllVesselGroupSearchVessels, selectHasVesselGroupSearchVessels, selectHasVesselGroupVesselsOverflow, selectVesselGroupWorkspaceToNavigate, @@ -34,33 +33,36 @@ 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 { getVesselId } from 'features/vessel/vessel.utils' 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 { IdField, - resetVesselGroup, createVesselGroupThunk, selectVesselGroupById, - selectVesselGroupEditId, - selectVesselGroupModalOpen, - selectVesselGroupSearchId, - selectVesselGroupSearchStatus, selectVesselGroupsStatus, - selectVesselGroupsVessels, - setVesselGroupSearchId, - resetVesselGroupStatus, - setVesselGroupSearchVessels, - searchVesselGroupsVesselsThunk, - MAX_VESSEL_GROUP_SEARCH_VESSELS, - MAX_VESSEL_GROUP_VESSELS, - getVesselInVesselGroupThunk, - selectVesselGroupConfirmationMode, VesselGroupConfirmationMode, updateVesselGroupVesselsThunk, + UpdateVesselGroupThunkParams, } from './vessel-groups.slice' import styles from './VesselGroupModal.module.css' +import { + getVesselInVesselGroupThunk, + MAX_VESSEL_GROUP_VESSELS, + resetVesselGroupModal, + resetVesselGroupModalSearchStatus, + searchVesselGroupsVesselsThunk, + selectVesselGroupConfirmationMode, + selectVesselGroupEditId, + selectVesselGroupModalOpen, + selectVesselGroupModalSearchIdField, + selectVesselGroupModalVessels, + selectVesselGroupSearchStatus, + selectVesselGroupsModalSearchIds, + setVesselGroupModalVessels, + setVesselGroupSearchIdField, +} from './vessel-groups-modal.slice' +import { getVesselGroupUniqVessels } from './vessel-groups.utils' function VesselGroupModal(): React.ReactElement { const { t } = useTranslation() @@ -69,9 +71,9 @@ function VesselGroupModal(): React.ReactElement { const vesselDataviews = useSelector(selectVesselsDataviews) const isModalOpen = useSelector(selectVesselGroupModalOpen) const confirmationMode = useSelector(selectVesselGroupConfirmationMode) - const searchIdField = useSelector(selectVesselGroupSearchId) + const searchIdField = useSelector(selectVesselGroupModalSearchIdField) const editingVesselGroupId = useSelector(selectVesselGroupEditId) - const vesselGroupVessels = useSelector(selectVesselGroupsVessels) + const vesselGroupVesselsToSearch = useSelector(selectVesselGroupsModalSearchIds) const editingVesselGroup = useSelector(selectVesselGroupById(editingVesselGroupId as string)) const searchVesselStatus = useSelector(selectVesselGroupSearchStatus) const vesselGroupsStatus = useSelector(selectVesselGroupsStatus) @@ -90,20 +92,20 @@ function VesselGroupModal(): React.ReactElement { const [groupName, setGroupName] = useState(editingVesselGroup?.name || '') const [showBackButton, setShowBackButton] = useState(false) const [createAsPublic, setCreateAsPublic] = useState(true) - const vesselGroupSearchVessels = useSelector(selectAllVesselGroupSearchVessels) + const vesselGroupVessels = useSelector(selectVesselGroupModalVessels) const hasVesselsOverflow = useSelector(selectHasVesselGroupVesselsOverflow) const hasVesselGroupsVessels = useSelector(selectHasVesselGroupSearchVessels) const vesselGroupsInWorkspace = useSelector(selectWorkspaceVessselGroupsIds) const { upsertDataviewInstance } = useDataviewInstancesConnect() const searchVesselGroupsVesselsRef = useRef() - const searchVesselGroupsVesselsAllowed = vesselGroupVessels - ? vesselGroupVessels?.length < MAX_VESSEL_GROUP_SEARCH_VESSELS + const searchVesselGroupsVesselsAllowed = vesselGroupVesselsToSearch + ? vesselGroupVesselsToSearch?.length < MAX_VESSEL_GROUP_VESSELS : true const dispatchSearchVesselsGroupsThunk = useCallback( - async (vessels: VesselGroupVessel[], idField: IdField = 'vesselId') => { + async (ids: string[], idField: IdField = 'vesselId') => { searchVesselGroupsVesselsRef.current = dispatch( - searchVesselGroupsVesselsThunk({ vessels, idField }) + searchVesselGroupsVesselsThunk({ ids, idField }) ) const action = await searchVesselGroupsVesselsRef.current if (searchVesselGroupsVesselsThunk.fulfilled.match(action)) { @@ -127,7 +129,7 @@ function VesselGroupModal(): React.ReactElement { const onIdFieldChange = useCallback( (option: SelectOption) => { - dispatch(setVesselGroupSearchId(option.id)) + dispatch(setVesselGroupSearchIdField(option.id)) }, [dispatch] ) @@ -141,7 +143,7 @@ function VesselGroupModal(): React.ReactElement { const close = useCallback(() => { setError('') setGroupName('') - dispatch(resetVesselGroup('')) + dispatch(resetVesselGroupModal()) abortSearch() }, [abortSearch, dispatch]) @@ -158,8 +160,8 @@ function VesselGroupModal(): React.ReactElement { if (confirmed) { if (action === 'back') { setError('') - dispatch(setVesselGroupSearchVessels(undefined)) - dispatch(resetVesselGroupStatus('')) + dispatch(setVesselGroupModalVessels(null)) + dispatch(resetVesselGroupModalSearchStatus()) abortSearch() setShowBackButton(false) } else { @@ -172,10 +174,10 @@ function VesselGroupModal(): React.ReactElement { const onSearchVesselsClick = useCallback(async () => { setShowBackButton(true) - if (vesselGroupVessels) { - dispatchSearchVesselsGroupsThunk(vesselGroupVessels, searchIdField) + if (vesselGroupVesselsToSearch) { + dispatchSearchVesselsGroupsThunk(vesselGroupVesselsToSearch, searchIdField) } - }, [dispatchSearchVesselsGroupsThunk, vesselGroupVessels, searchIdField]) + }, [dispatchSearchVesselsGroupsThunk, vesselGroupVesselsToSearch, searchIdField]) const onCreateGroupClick = useCallback( async ( @@ -183,18 +185,14 @@ function VesselGroupModal(): React.ReactElement { { addToDataviews = true, removeVessels = false, navigateToWorkspace = false } = {} ) => { setButtonLoading(navigateToWorkspace ? 'saveAndSeeInWorkspace' : 'save') - const vessels: VesselGroupVessel[] = vesselGroupSearchVessels.map((vessel) => { - return { - vesselId: getVesselId(vessel), - dataset: vessel.dataset as string, - } - }) + const vessels: VesselGroupVessel[] = getVesselGroupUniqVessels(vesselGroupVessels) let dispatchedAction if (editingVesselGroupId) { - const vesselGroup = { + const vesselGroup: UpdateVesselGroupThunkParams = { id: editingVesselGroupId, name: groupName, vessels, + override: true, } dispatchedAction = await dispatch(updateVesselGroupVesselsThunk(vesselGroup)) } else { @@ -262,7 +260,7 @@ function VesselGroupModal(): React.ReactElement { }) }, [ - vesselGroupSearchVessels, + vesselGroupVessels, editingVesselGroupId, groupName, dispatch, @@ -341,9 +339,9 @@ function VesselGroupModal(): React.ReactElement {
    {!editingVesselGroup && (
    - {vesselGroupSearchVessels?.length > 0 && ( + {vesselGroupVessels && vesselGroupVessels?.length > 0 && ( )} {t('vesselGroup.searchLimit', { defaultValue: 'Search is limited up to {{limit}} vessels', - limit: MAX_VESSEL_GROUP_SEARCH_VESSELS, + limit: MAX_VESSEL_GROUP_VESSELS, })} )} @@ -388,7 +386,9 @@ function VesselGroupModal(): React.ReactElement { )} {!fullModalLoading && - (confirmationMode === 'save' || confirmationMode === 'saveAndDeleteVessels' ? ( + (confirmationMode === 'save' || + confirmationMode === 'update' || + confirmationMode === 'saveAndDeleteVessels' ? ( ) : ( diff --git a/apps/fishing-map/features/vessel-groups/VesselGroupModalSearch.tsx b/apps/fishing-map/features/vessel-groups/VesselGroupModalSearch.tsx index 96265e5891..e2a917dba0 100644 --- a/apps/fishing-map/features/vessel-groups/VesselGroupModalSearch.tsx +++ b/apps/fishing-map/features/vessel-groups/VesselGroupModalSearch.tsx @@ -9,11 +9,11 @@ import { readBlobAs } from 'utils/files' import { useAppDispatch } from 'features/app/app.hooks' import { ID_COLUMN_LOOKUP } from 'features/vessel-groups/vessel-groups.config' import { - selectVesselGroupSearchId, - selectVesselGroupsVessels, - setVesselGroupSearchId, - setVesselGroupVessels, -} from './vessel-groups.slice' + selectVesselGroupModalSearchIdField, + selectVesselGroupsModalSearchIds, + setVesselGroupSearchIdField, + setVesselGroupModalSearchIds, +} from './vessel-groups-modal.slice' import styles from './VesselGroupModal.module.css' function VesselGroupSearch({ onError }: { onError: (string: any) => void }) { @@ -21,16 +21,17 @@ function VesselGroupSearch({ onError }: { onError: (string: any) => void }) { const dispatch = useAppDispatch() const [searchText, setSearchText] = useState('') const debouncedSearchText = useDebounce(searchText, 200) - const searchIdField = useSelector(selectVesselGroupSearchId) - const vesselGroupVessels = useSelector(selectVesselGroupsVessels) - const hasGroupVessels = vesselGroupVessels && vesselGroupVessels.length > 0 + const searchIdField = useSelector(selectVesselGroupModalSearchIdField) + const vesselGroupVesselsToSearch = useSelector(selectVesselGroupsModalSearchIds) + const hasGroupVesselsToSearch = + vesselGroupVesselsToSearch && vesselGroupVesselsToSearch.length > 0 useEffect(() => { if (debouncedSearchText) { const vesselIds = debouncedSearchText?.split(/[\s|,]+/).filter(Boolean) - dispatch(setVesselGroupVessels(vesselIds.map((v) => ({ vesselId: v, dataset: '' })))) + dispatch(setVesselGroupModalSearchIds(vesselIds)) } else { - dispatch(setVesselGroupVessels(undefined)) + dispatch(setVesselGroupModalSearchIds(null)) } }, [dispatch, debouncedSearchText]) @@ -54,7 +55,7 @@ function VesselGroupSearch({ onError }: { onError: (string: any) => void }) { const presetColumn = ID_COLUMN_LOOKUP[i] foundIdColumn = columns.find((c) => c.toLowerCase() === presetColumn) if (foundIdColumn) { - dispatch(setVesselGroupSearchId(presetColumn)) + dispatch(setVesselGroupSearchIdField(presetColumn)) break } } @@ -86,7 +87,9 @@ function VesselGroupSearch({ onError }: { onError: (string: any) => void }) { className={styles.idsArea} value={searchText} label={ - hasGroupVessels ? `${searchIdField} (${vesselGroupVessels?.length})` : searchIdField + hasGroupVesselsToSearch + ? `${searchIdField} (${vesselGroupVesselsToSearch?.length})` + : searchIdField } placeholder={t('vesselGroup.idsPlaceholder', { field: searchIdField, diff --git a/apps/fishing-map/features/vessel-groups/VesselGroupModalVessels.tsx b/apps/fishing-map/features/vessel-groups/VesselGroupModalVessels.tsx index e5b44efda9..255d4383bf 100644 --- a/apps/fishing-map/features/vessel-groups/VesselGroupModalVessels.tsx +++ b/apps/fishing-map/features/vessel-groups/VesselGroupModalVessels.tsx @@ -1,33 +1,25 @@ -import { Fragment, useCallback } from 'react' +import { useCallback } from 'react' import { useSelector } from 'react-redux' import { useTranslation } from 'react-i18next' -import { groupBy } from 'es-toolkit' -import { ActionCreatorWithPayload } from '@reduxjs/toolkit' import { IconButton, Tooltip, TransmissionsTimeline } from '@globalfishingwatch/ui-components' -import { IdentityVessel, Locale, VesselRegistryInfo } from '@globalfishingwatch/api-types' +import { Locale } from '@globalfishingwatch/api-types' import { EMPTY_FIELD_PLACEHOLDER, formatInfoField, getVesselGearTypeLabel } from 'utils/info' import { FIRST_YEAR_OF_DATA } from 'data/config' import I18nDate from 'features/i18n/i18nDate' import { useAppDispatch } from 'features/app/app.hooks' -import { - getLatestIdentityPrioritised, - getVesselProperty, - isFieldLoginRequired, -} from 'features/vessel/vessel.utils' -import { VesselDataIdentity } from 'features/vessel/vessel.slice' +import { getSearchIdentityResolved, isFieldLoginRequired } from 'features/vessel/vessel.utils' import VesselIdentityFieldLogin from 'features/vessel/identity/VesselIdentityFieldLogin' -import { - setVesselGroupSearchVessels, - selectVesselGroupSearchVessels, - selectNewVesselGroupSearchVessels, - setNewVesselGroupSearchVessels, -} from './vessel-groups.slice' import styles from './VesselGroupModal.module.css' +import { + VesselGroupVesselIdentity, + selectVesselGroupModalVessels, + setVesselGroupModalVessels, +} from './vessel-groups-modal.slice' type VesselGroupVesselRowProps = { - vessel: VesselDataIdentity + vessel: VesselGroupVesselIdentity className?: string - onRemoveClick: (vessel: VesselDataIdentity) => void + onRemoveClick: (vessel: VesselGroupVesselIdentity) => void } function VesselGroupVesselRow({ vessel, @@ -35,32 +27,24 @@ function VesselGroupVesselRow({ className = '', }: VesselGroupVesselRowProps) { const { t, i18n } = useTranslation() - const { shipname, flag, ssvid, transmissionDateFrom, transmissionDateTo } = - vessel || ({} as VesselRegistryInfo) + const { shipname, flag, ssvid, transmissionDateFrom, transmissionDateTo, geartypes } = + getSearchIdentityResolved(vessel.identity!) const vesselName = formatInfoField(shipname, 'name') - const vesselGearType = getVesselGearTypeLabel(vessel) + const vesselGearType = getVesselGearTypeLabel({ geartypes }) return ( {ssvid} {vesselName} - - - {isFieldLoginRequired(vesselGearType) ? ( - - ) : ( - vesselGearType || EMPTY_FIELD_PLACEHOLDER - )} - - + {flag ? t(`flags:${flag as string}` as any) : EMPTY_FIELD_PLACEHOLDER} {isFieldLoginRequired(vesselGearType) ? : vesselGearType} {transmissionDateFrom && transmissionDateTo && ( - // TODO tooltip not working + // TODO:VV3 tooltip not working @@ -95,62 +79,27 @@ function VesselGroupVesselRow({ ) } -type VesselGroupDataIdentity = VesselDataIdentity & { dataset: string } -function groupSearchVesselsIdentityBy(vessels: IdentityVessel[] | null, groupByKey: string) { - if (!vessels?.length) { - return {} - } - return groupBy( - vessels.map( - (v) => - ({ - dataset: v.dataset, - ...getLatestIdentityPrioritised(v), - } as VesselGroupDataIdentity) - ), - (v) => (v as any)[groupByKey] - ) -} - function VesselGroupVessels() { const { t } = useTranslation() - const vesselGroupSearchVessels = useSelector(selectVesselGroupSearchVessels) - const newVesselGroupSearchVessels = useSelector(selectNewVesselGroupSearchVessels) - const groupByKey = [ - ...(vesselGroupSearchVessels || []), - ...(newVesselGroupSearchVessels || []), - ].some((vessel) => { - const ssvid = getVesselProperty(vessel, 'ssvid') - return ssvid !== undefined && ssvid !== '' - }) - ? 'ssvid' - : 'id' - const searchVesselsGrouped = groupSearchVesselsIdentityBy(vesselGroupSearchVessels, groupByKey) - const newSearchVesselsGrouped = groupSearchVesselsIdentityBy( - newVesselGroupSearchVessels, - groupByKey - ) const dispatch = useAppDispatch() + const vesselGroupVessels = useSelector(selectVesselGroupModalVessels) const onVesselRemoveClick = useCallback( - (vessel: VesselGroupDataIdentity, list: 'search' | 'new' = 'search') => { - const vessels = list === 'search' ? vesselGroupSearchVessels : newVesselGroupSearchVessels - const action = ( - list === 'search' ? setVesselGroupSearchVessels : setNewVesselGroupSearchVessels - ) as ActionCreatorWithPayload - const index = vessels!.findIndex( - (v) => getLatestIdentityPrioritised(v).id === vessel?.id && v.dataset === vessel.dataset - ) - if (index > -1) { - dispatch(action([...vessels!.slice(0, index), ...vessels!.slice(index + 1)])) + (vessel: VesselGroupVesselIdentity) => { + if (vesselGroupVessels) { + const filteredVessels = vesselGroupVessels.filter( + (v) => v.vesselId !== vessel.vesselId && v.relationId !== vessel.vesselId + ) + dispatch(setVesselGroupModalVessels(filteredVessels)) } }, - [dispatch, newVesselGroupSearchVessels, vesselGroupSearchVessels] + [dispatch, vesselGroupVessels] ) - if (!vesselGroupSearchVessels?.length && !newVesselGroupSearchVessels?.length) { + if (!vesselGroupVessels?.length) { return null } + return ( @@ -164,44 +113,19 @@ function VesselGroupVessels() { - {Object.keys(newSearchVesselsGrouped)?.length > 0 && - Object.keys(newSearchVesselsGrouped).map((mmsi) => { - if (!mmsi || mmsi === 'undefined') { - return null - } - const vessels = newSearchVesselsGrouped[mmsi] - return vessels.map((vessel) => ( - - onVesselRemoveClick(vessel as VesselGroupDataIdentity, 'new') - } - /> - )) - })} - {Object.keys(searchVesselsGrouped)?.length > 0 && - Object.keys(searchVesselsGrouped).map((mmsi) => { - if (newSearchVesselsGrouped[mmsi]) { - return null - } - const vessels = searchVesselsGrouped[mmsi] - return ( - - {vessels.map((vessel, i) => ( - - onVesselRemoveClick(vessel as VesselGroupDataIdentity) - } - className={i === vessels.length - 1 ? styles.border : ''} - /> - ))} - - ) - })} + {vesselGroupVessels?.map((vessel) => { + if (!vessel.identity) { + return null + } + 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 new file mode 100644 index 0000000000..cb8ad901e1 --- /dev/null +++ b/apps/fishing-map/features/vessel-groups/vessel-groups-modal.slice.ts @@ -0,0 +1,358 @@ +import { createAsyncThunk, PayloadAction, createSlice } from '@reduxjs/toolkit' +import { uniq } from 'es-toolkit' +import { RootState } from 'reducers' +import { + APIPagination, + APIVesselSearchPagination, + DatasetStatus, + DataviewDatasetConfig, + EndpointId, + IdentityVessel, + VesselGroup, + VesselGroupVessel, +} from '@globalfishingwatch/api-types' +import { GFWAPI, parseAPIError, ParsedAPIError } from '@globalfishingwatch/api-client' +import { resolveEndpoint } from '@globalfishingwatch/datasets-client' +import { selectVesselsDatasets } from 'features/datasets/datasets.selectors' +import { AsyncReducerStatus } from 'utils/async-slice' +import { INCLUDES_RELATED_SELF_REPORTED_INFO_ID } from 'features/vessel/vessel.config' +import { fetchDatasetByIdThunk, selectDatasetById } from '../datasets/datasets.slice' +import { + flatVesselGroupSearchVessels, + mergeVesselGroupVesselIdentities, +} from './vessel-groups.utils' + +export const MAX_VESSEL_GROUP_VESSELS = 1000 + +export type IdField = 'vesselId' | 'mmsi' +export type VesselGroupConfirmationMode = + | 'save' + | 'update' + | 'saveAndSeeInWorkspace' + | 'saveAndDeleteVessels' + +export type VesselGroupVesselIdentity = VesselGroupVessel & { identity?: IdentityVessel } + +interface VesselGroupModalState { + isModalOpen: boolean + vesselGroupEditId: string | null + confirmationMode: VesselGroupConfirmationMode + vessels: VesselGroupVesselIdentity[] | null + search: { + idField: IdField + ids: string[] | null + status: AsyncReducerStatus + error: ParsedAPIError | null + } +} + +type SearchVesselsBody = { datasets: string[]; where?: string; ids?: string[] } +type FetchSearchVessels = { url: string; body?: SearchVesselsBody; signal?: AbortSignal } + +const fetchSearchVessels = async ({ + url, + body, + signal, + token, +}: FetchSearchVessels & { token?: string }) => { + const searchResponse = await GFWAPI.fetch>( + `${url}${token ? `&since=${token}` : ''}`, + { + signal, + ...(body && { + method: 'POST', + body, + }), + } + ) + return searchResponse +} + +const SEARCH_PAGINATION = 25 +const fetchAllSearchVessels = async (params: FetchSearchVessels) => { + let searchResults = [] as IdentityVessel[] + let pendingResults = true + let paginationToken = '' + while (pendingResults) { + const searchResponse = await fetchSearchVessels({ ...params, token: paginationToken }) + searchResults = searchResults.concat(searchResponse.entries) + if (searchResponse.since && searchResults!?.length < searchResponse.total) { + paginationToken = searchResponse.since + } else { + pendingResults = false + } + } + return searchResults +} + +const initialState: VesselGroupModalState = { + isModalOpen: false, + vesselGroupEditId: null, + confirmationMode: 'save', + vessels: null, + search: { + idField: 'mmsi', + ids: null, + status: AsyncReducerStatus.Idle, + error: null, + }, +} + +export const searchVesselGroupsVesselsThunk = createAsyncThunk( + 'vessel-groups/searchVessels', + async ( + { ids, idField, datasets = [] }: { ids: string[]; idField: IdField; datasets?: string[] }, + { signal, rejectWithValue, getState } + ) => { + 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') + ) + /*&& d.alias?.some((alias) => alias.includes(':latest'))*/ + }) + + if (searchDatasets?.length) { + const dataset = searchDatasets[0] + const datasets = searchDatasets.map((d) => d.id) + const uniqVesselIds = uniq(ids) + const isVesselByIdSearch = idField === 'vesselId' + const datasetConfig: DataviewDatasetConfig = { + endpoint: isVesselByIdSearch ? EndpointId.VesselList : EndpointId.VesselSearch, + datasetId: '', + params: [], + query: [{ id: 'cache', value: false }], + } + if (isVesselByIdSearch) { + datasetConfig.query?.push({ id: 'ids', value: uniqVesselIds }) + datasetConfig.query?.push({ + id: 'datasets', + value: datasets, + }) + } else { + datasetConfig.query?.push({ + id: 'limit', + value: SEARCH_PAGINATION, + }) + datasetConfig.query?.push({ + id: 'includes', + value: [INCLUDES_RELATED_SELF_REPORTED_INFO_ID], + }) + } + try { + const url = resolveEndpoint(dataset, datasetConfig) + if (!url) { + console.warn('Missing search url') + return rejectWithValue({ + code: 0, + message: 'Missing search url', + }) + } + const fetchBody = isVesselByIdSearch + ? undefined + : { + datasets, + where: `${uniqVesselIds.map((ssvid) => `ssvid='${ssvid}'`).join(' OR ')}`, + } + const searchResults = await fetchAllSearchVessels({ + url: `${url}`, + body: fetchBody, + signal, + }) + + const vesselGroupVessels = flatVesselGroupSearchVessels(searchResults) + return vesselGroupVessels + } catch (e: any) { + console.warn(e) + return rejectWithValue(parseAPIError(e)) + } + } else { + console.warn('No search datasets found') + return rejectWithValue({ + code: 0, + message: 'No search datasets found', + }) + } + }, + { + condition: (_, { getState }) => { + const workspaceVesselGroupsStatus = (getState() as RootState).vesselGroupModal.search.status + // Fetched already in progress, don't need to re-fetch + return workspaceVesselGroupsStatus !== AsyncReducerStatus.Loading + }, + } +) + +export const getVesselInVesselGroupThunk = createAsyncThunk( + 'vessel-groups/getVessels', + async ( + { vesselGroup }: { vesselGroup: VesselGroup }, + { signal, rejectWithValue, getState, dispatch } + ) => { + const state = getState() as any + const datasets = uniq(vesselGroup.vessels.flatMap((v) => v.dataset || [])) + const datasetId = datasets[0] + let dataset = selectDatasetById(datasetId)(state) + if (!dataset) { + const action = await dispatch(fetchDatasetByIdThunk(datasetId)) + if (fetchDatasetByIdThunk.fulfilled.match(action)) { + dataset = action.payload + } + } + if (vesselGroup.id && dataset) { + const datasetConfig: DataviewDatasetConfig = { + endpoint: EndpointId.VesselList, + datasetId: '', + params: [], + query: [ + { + id: 'vessel-groups', + value: vesselGroup.id, + }, + { + id: 'cache', + value: false, + }, + { + id: 'includes', + value: [INCLUDES_RELATED_SELF_REPORTED_INFO_ID], + }, + ], + } + try { + const url = resolveEndpoint(dataset, datasetConfig) + if (!url) { + console.warn('Missing search url') + return rejectWithValue({ + code: 0, + message: 'Missing search url', + }) + } + const vesselsIdentities = await GFWAPI.fetch>(url, { + signal, + cache: 'reload', + }) + const vesselGroupVessels = mergeVesselGroupVesselIdentities( + vesselGroup.vessels, + vesselsIdentities.entries + ) + console.log('🚀 ~ vesselGroupVessels:', vesselGroup.vessels) + console.log('🚀 ~ vesselGroupVessels:', vesselGroupVessels) + return vesselGroupVessels + } catch (e: any) { + console.warn(e) + return rejectWithValue(parseAPIError(e)) + } + } else { + console.warn('No search datasets found') + return rejectWithValue({ + code: 0, + message: 'No search datasets found', + }) + } + }, + { + condition: (_, { getState }) => { + const workspaceVesselGroupsStatus = (getState() as RootState).vesselGroupModal.search.status + // Fetched already in progress, don't need to re-fetch + return workspaceVesselGroupsStatus !== AsyncReducerStatus.Loading + }, + } +) + +export const vesselGroupModalSlice = createSlice({ + name: 'vesselGroupModal', + initialState, + reducers: { + setVesselGroupsModalOpen: (state, action: PayloadAction) => { + state.isModalOpen = action.payload + }, + setVesselGroupSearchIdField: (state, action: PayloadAction) => { + state.search.idField = action.payload + }, + resetVesselGroupModalSearchStatus: (state) => { + state.search.status = AsyncReducerStatus.Idle + }, + setVesselGroupModalVessels: ( + state, + action: PayloadAction + ) => { + state.vessels = action.payload + }, + setVesselGroupModalSearchIds: (state, action: PayloadAction) => { + state.search.ids = action.payload + }, + setVesselGroupEditId: (state, action: PayloadAction) => { + state.vesselGroupEditId = action.payload + }, + setVesselGroupConfirmationMode: (state, action: PayloadAction) => { + state.confirmationMode = action.payload + }, + resetVesselGroupModal: (state) => { + return { ...initialState } + }, + }, + extraReducers(builder) { + builder.addCase(searchVesselGroupsVesselsThunk.pending, (state) => { + state.search.status = AsyncReducerStatus.Loading + state.vessels = null + }) + builder.addCase(searchVesselGroupsVesselsThunk.fulfilled, (state, action) => { + state.search.status = AsyncReducerStatus.Finished + state.vessels = action.payload + }) + builder.addCase(searchVesselGroupsVesselsThunk.rejected, (state, action) => { + if (action.error.message === 'Aborted') { + state.search.status = AsyncReducerStatus.Idle + } else { + state.search.status = AsyncReducerStatus.Error + state.search.error = action.payload as ParsedAPIError + } + }) + builder.addCase(getVesselInVesselGroupThunk.pending, (state) => { + state.search.status = AsyncReducerStatus.Loading + state.vessels = null + }) + builder.addCase(getVesselInVesselGroupThunk.fulfilled, (state, action) => { + state.search.status = AsyncReducerStatus.Finished + state.vessels = action.payload + }) + builder.addCase(getVesselInVesselGroupThunk.rejected, (state, action) => { + if (action.error.message === 'Aborted') { + state.search.status = AsyncReducerStatus.Idle + } else { + state.search.status = AsyncReducerStatus.Error + state.search.error = action.payload as ParsedAPIError + } + }) + }, +}) + +export const { + setVesselGroupsModalOpen, + setVesselGroupSearchIdField, + resetVesselGroupModalSearchStatus, + setVesselGroupModalVessels, + setVesselGroupModalSearchIds, + setVesselGroupEditId, + setVesselGroupConfirmationMode, + resetVesselGroupModal, +} = vesselGroupModalSlice.actions + +export const selectVesselGroupModalOpen = (state: RootState) => state.vesselGroupModal.isModalOpen +export const selectVesselGroupModalSearchIdField = (state: RootState) => + state.vesselGroupModal.search.idField +export const selectVesselGroupSearchStatus = (state: RootState) => + state.vesselGroupModal.search.status +export const selectVesselGroupModalVessels = (state: RootState) => state.vesselGroupModal.vessels +export const selectVesselGroupsModalSearchIds = (state: RootState) => + state.vesselGroupModal.search.ids +export const selectVesselGroupEditId = (state: RootState) => + state.vesselGroupModal.vesselGroupEditId +export const selectVesselGroupConfirmationMode = (state: RootState) => + state.vesselGroupModal.confirmationMode + +export default vesselGroupModalSlice.reducer 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 46066d7ed9..1a413d3248 100644 --- a/apps/fishing-map/features/vessel-groups/vessel-groups.config.ts +++ b/apps/fishing-map/features/vessel-groups/vessel-groups.config.ts @@ -8,3 +8,6 @@ export const ID_COLUMNS_OPTIONS: SelectOption[] = ID_COLUMN_LOOKUP.map((key) => id: key, label: key.toUpperCase(), })) + +// TODO:VV3 set the proper date +export const VESSEL_GROUPS_REPORT_RELEASE_DATE = '2024-09-25' diff --git a/apps/fishing-map/features/vessel-groups/vessel-groups.hooks.ts b/apps/fishing-map/features/vessel-groups/vessel-groups.hooks.ts index 8b801669c7..db39fe62cf 100644 --- a/apps/fishing-map/features/vessel-groups/vessel-groups.hooks.ts +++ b/apps/fishing-map/features/vessel-groups/vessel-groups.hooks.ts @@ -6,26 +6,25 @@ import { VesselGroup } from '@globalfishingwatch/api-types' import { selectAllVisibleVesselGroups } from 'features/user/selectors/user.permissions.selectors' import { getVesselGroupLabel } from 'features/vessel-groups/vessel-groups.utils' import { IdentityVesselData } from 'features/vessel/vessel.slice' -import { getCurrentIdentityVessel } from 'features/vessel/vessel.utils' -import { VesselLastIdentity } from 'features/search/search.slice' -import { ReportVesselWithDatasets } from 'features/reports/areas/area-reports.selectors' +// import { VesselLastIdentity } from 'features/search/search.slice' +// import { ReportVesselWithDatasets } from 'features/reports/areas/area-reports.selectors' import { useAppDispatch } from 'features/app/app.hooks' import { sortByCreationDate } from 'utils/dates' import { selectVesselGroupsStatusId, - setNewVesselGroupSearchVessels, - setVesselGroupEditId, - setVesselGroupsModalOpen, UpdateVesselGroupThunkParams, updateVesselGroupVesselsThunk, } from './vessel-groups.slice' +import { + setVesselGroupEditId, + setVesselGroupModalVessels, + setVesselGroupsModalOpen, + VesselGroupVesselIdentity, +} from './vessel-groups-modal.slice' export const NEW_VESSEL_GROUP_ID = 'new-vessel-group' -export type AddVesselGroupVessel = - | VesselLastIdentity - | ReportVesselWithDatasets - | IdentityVesselData +export type AddVesselGroupVessel = IdentityVesselData | VesselGroupVesselIdentity export const useVesselGroupsOptions = () => { const { t } = useTranslation() @@ -49,19 +48,10 @@ export const useVesselGroupsOptions = () => { export const useVesselGroupsUpdate = () => { const dispatch = useAppDispatch() const addVesselsToVesselGroup = useCallback( - async (vesselGroupId: string, vessels: AddVesselGroupVessel[]) => { + async (vesselGroupId: string, vessels: VesselGroupVesselIdentity[]) => { const vesselGroup: UpdateVesselGroupThunkParams = { id: vesselGroupId, - vessels: vessels.flatMap((vessel) => { - const { id, dataset } = getCurrentIdentityVessel(vessel as IdentityVesselData) - if (!id || !dataset) { - return [] - } - return { - vesselId: id, - dataset: typeof dataset === 'string' ? dataset : dataset.id, - } - }), + vessels: vessels, } const dispatchedAction = await dispatch(updateVesselGroupVesselsThunk(vesselGroup)) if (updateVesselGroupVesselsThunk.fulfilled.match(dispatchedAction)) { @@ -79,23 +69,15 @@ export const useVesselGroupsUpdate = () => { export const useVesselGroupsModal = () => { const dispatch = useAppDispatch() const createVesselGroupWithVessels = useCallback( - async (vesselGroupId: string, vessels: AddVesselGroupVessel[]) => { - const vesselsWithDataset = vessels.map((vessel) => ({ - ...vessel, - id: (vessel as VesselLastIdentity)?.id || (vessel as ReportVesselWithDatasets)?.vesselId, - dataset: - typeof vessel?.dataset === 'string' - ? vessel.dataset - : vessel.dataset?.id || (vessel as ReportVesselWithDatasets)?.infoDataset?.id, - })) - if (vesselsWithDataset?.length) { + async (vesselGroupId: string, vessels: VesselGroupVesselIdentity[]) => { + if (vessels?.length) { if (vesselGroupId && vesselGroupId !== NEW_VESSEL_GROUP_ID) { dispatch(setVesselGroupEditId(vesselGroupId)) } - dispatch(setNewVesselGroupSearchVessels(vesselsWithDataset)) + dispatch(setVesselGroupModalVessels(vessels)) dispatch(setVesselGroupsModalOpen(true)) } else { - console.warn('No related activity datasets founds for', vesselsWithDataset) + console.warn('No related activity datasets founds for', vessels) } }, [dispatch] diff --git a/apps/fishing-map/features/vessel-groups/vessel-groups.selectors.ts b/apps/fishing-map/features/vessel-groups/vessel-groups.selectors.ts index d54c4d796a..9e8787ad3a 100644 --- a/apps/fishing-map/features/vessel-groups/vessel-groups.selectors.ts +++ b/apps/fishing-map/features/vessel-groups/vessel-groups.selectors.ts @@ -1,11 +1,7 @@ import { createSelector } from '@reduxjs/toolkit' import { isAdvancedSearchAllowed } from 'features/search/search.selectors' import { selectLocationQuery, selectUrlDataviewInstances } from 'routes/routes.selectors' -import { - MAX_VESSEL_GROUP_VESSELS, - selectNewVesselGroupSearchVessels, - selectVesselGroupSearchVessels, -} from 'features/vessel-groups/vessel-groups.slice' +import { MAX_VESSEL_GROUP_VESSELS } from 'features/vessel-groups/vessel-groups-modal.slice' import { selectLastVisitedWorkspace, selectWorkspace, @@ -16,25 +12,19 @@ import { LastWorkspaceVisited } from 'features/workspace/workspace.slice' import { WORKSPACE } from 'routes/routes' import { DEFAULT_WORKSPACE_CATEGORY, DEFAULT_WORKSPACE_ID } from 'data/workspaces' import { getVesselGroupsInDataviews } from 'features/datasets/datasets.utils' - -export const selectAllVesselGroupSearchVessels = createSelector( - [selectVesselGroupSearchVessels, selectNewVesselGroupSearchVessels], - (vessels, newVessels) => { - return [...(newVessels || []), ...(vessels || [])] - } -) +import { selectVesselGroupModalVessels } from './vessel-groups-modal.slice' export const selectHasVesselGroupVesselsOverflow = createSelector( - [selectAllVesselGroupSearchVessels], + [selectVesselGroupModalVessels], (vessels = []) => { - return vessels.length > MAX_VESSEL_GROUP_VESSELS + return vessels !== null && vessels.length > MAX_VESSEL_GROUP_VESSELS } ) export const selectHasVesselGroupSearchVessels = createSelector( - [selectAllVesselGroupSearchVessels], + [selectVesselGroupModalVessels], (vessels = []) => { - return vessels.length > 0 + return vessels !== null && vessels.length > 0 } ) 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 9157b6ad25..1ad3392b0d 100644 --- a/apps/fishing-map/features/vessel-groups/vessel-groups.slice.ts +++ b/apps/fishing-map/features/vessel-groups/vessel-groups.slice.ts @@ -1,21 +1,9 @@ -import { createAsyncThunk, PayloadAction, createSelector } from '@reduxjs/toolkit' +import { createAsyncThunk, createSelector, PayloadAction } from '@reduxjs/toolkit' import { stringify } from 'qs' -import { uniq, uniqBy } from 'es-toolkit' +import { uniqBy } from 'es-toolkit' import memoize from 'lodash/memoize' -import { - APIPagination, - APIVesselSearchPagination, - DatasetStatus, - DataviewDatasetConfig, - EndpointId, - IdentityVessel, - VesselGroup, - VesselGroupUpsert, - VesselGroupVessel, -} from '@globalfishingwatch/api-types' +import { APIPagination, VesselGroup, VesselGroupUpsert } from '@globalfishingwatch/api-types' import { GFWAPI, FetchOptions, parseAPIError, ParsedAPIError } from '@globalfishingwatch/api-client' -import { resolveEndpoint } from '@globalfishingwatch/datasets-client' -import { selectVesselsDatasets } from 'features/datasets/datasets.selectors' import { AsyncError, asyncInitialState, @@ -24,257 +12,28 @@ import { createAsyncSlice, } from 'utils/async-slice' import { DEFAULT_PAGINATION_PARAMS } from 'data/config' -import { getVesselId } from 'features/vessel/vessel.utils' import { RootState } from 'store' -import { fetchDatasetByIdThunk, selectDatasetById } from '../datasets/datasets.slice' - -export const MAX_VESSEL_GROUP_VESSELS = 1000 - -// Limitation of the API as we have a limitation of 2000 characters in GET requests -export const MAX_VESSEL_GROUP_SEARCH_VESSELS = 400 +import { prepareVesselGroupVesselsUpdate } from './vessel-groups.utils' export type IdField = 'vesselId' | 'mmsi' export type VesselGroupConfirmationMode = 'save' | 'saveAndSeeInWorkspace' | 'saveAndDeleteVessels' interface VesselGroupsState extends AsyncReducer { - isModalOpen: boolean - vesselGroupEditId: string | null - currentDataviewIds: string[] | null - confirmationMode: VesselGroupConfirmationMode - groupVessels: VesselGroupVessel[] | null - search: { - id: IdField - status: AsyncReducerStatus - error: ParsedAPIError | null - vessels: IdentityVessel[] | null - } - newSearchVessels: IdentityVessel[] | null workspace: { status: AsyncReducerStatus error: ParsedAPIError | null - vesselGroups: VesselGroup[] | null } } -const fetchSearchVessels = async ( - url: string, - { signal, token }: { signal?: AbortSignal; token?: string } -) => { - const searchResponse = await GFWAPI.fetch>( - `${url}${token ? `&since=${token}` : ''}`, - { - signal, - } - ) - return searchResponse -} - -const SEARCH_PAGINATION = 25 -const fetchAllSearchVessels = async (url: string, signal: AbortSignal) => { - let searchResults = [] as IdentityVessel[] - let pendingResults = true - let paginationToken = '' - while (pendingResults) { - const searchResponse = await fetchSearchVessels(url, { signal, token: paginationToken }) - searchResults = searchResults.concat(searchResponse.entries) - if (searchResponse.since && searchResults!?.length < searchResponse.total) { - paginationToken = searchResponse.since - } else { - pendingResults = false - } - } - return searchResults -} - const initialState: VesselGroupsState = { ...asyncInitialState, - isModalOpen: false, - vesselGroupEditId: null, - currentDataviewIds: null, - confirmationMode: 'save', - groupVessels: null, - search: { - id: 'mmsi', - status: AsyncReducerStatus.Idle, - vessels: null, - error: null, - }, - newSearchVessels: null, workspace: { status: AsyncReducerStatus.Idle, error: null, - vesselGroups: null, }, } type VesselGroupSliceState = { vesselGroups: VesselGroupsState } -export const searchVesselGroupsVesselsThunk = createAsyncThunk( - 'vessel-groups/searchVessels', - async ( - { vessels, idField }: { vessels: VesselGroupVessel[]; idField: IdField }, - { signal, rejectWithValue, getState } - ) => { - const state = getState() as any - const vesselGroupDatasets = uniq(vessels?.flatMap((v) => v.dataset || [])) - const allVesselDatasets = (selectVesselsDatasets(state) || []).filter( - (d) => - d.status !== DatasetStatus.Deleted && d.configuration?.apiSupportedVersions?.includes('v3') - /*&& d.alias?.some((alias) => alias.includes(':latest'))*/ - ) - - const searchDatasets = vesselGroupDatasets?.length - ? allVesselDatasets.filter((dataset) => vesselGroupDatasets.includes(dataset.id)) - : allVesselDatasets - - if (searchDatasets?.length) { - const dataset = searchDatasets[0] - const datasets = searchDatasets.map((d) => d.id) - const uniqVesselIds = uniq(vessels.map(({ vesselId }) => vesselId)) - const isVesselByIdSearch = idField === 'vesselId' - const datasetConfig: DataviewDatasetConfig = { - endpoint: isVesselByIdSearch ? EndpointId.VesselList : EndpointId.VesselSearch, - datasetId: searchDatasets[0].id, - params: [], - query: [ - { - id: 'datasets', - value: datasets, - }, - isVesselByIdSearch - ? { id: 'ids', value: uniqVesselIds } - : { - id: 'where', - value: encodeURIComponent( - `${uniqVesselIds.map((ssvid) => `ssvid='${ssvid}'`).join(' OR ')}` - ), - }, - ], - } - if (!isVesselByIdSearch) { - datasetConfig.query?.push({ - id: 'limit', - value: SEARCH_PAGINATION, - }) - } - try { - const url = resolveEndpoint(dataset, datasetConfig) - if (!url) { - console.warn('Missing search url') - return rejectWithValue({ - code: 0, - message: 'Missing search url', - }) - } - const searchResults = await fetchAllSearchVessels(`${url}&cache=false`, signal) - // API returns multiple instances of the same vessel with the same id and dataset - const uniqSearchResults = uniqBy(searchResults, (vessel) => - [getVesselId(vessel), vessel.dataset].join(',') - ) - // Searching could return same vessel id from different datasets so we need to choose the original one - const searchResultsFiltered = isVesselByIdSearch - ? uniqSearchResults.filter((vessel) => { - const vesselId = getVesselId(vessel) - return ( - vessels.find((v) => { - const isSameVesselid = v.vesselId === vesselId - const isSameDataset = v.dataset ? v.dataset === vessel.dataset : true - return isSameVesselid && isSameDataset - }) !== undefined - ) - }) - : uniqSearchResults - - return searchResultsFiltered - } catch (e: any) { - console.warn(e) - return rejectWithValue(parseAPIError(e)) - } - } else { - console.warn('No search datasets found') - return rejectWithValue({ - code: 0, - message: 'No search datasets found', - }) - } - }, - { - condition: (_, { getState }) => { - const workspaceVesselGroupsStatus = (getState() as VesselGroupSliceState).vesselGroups.search - .status - // Fetched already in progress, don't need to re-fetch - return workspaceVesselGroupsStatus !== AsyncReducerStatus.Loading - }, - } -) - -export const getVesselInVesselGroupThunk = createAsyncThunk( - 'vessel-groups/getVessels', - async ( - { vesselGroup }: { vesselGroup: VesselGroup }, - { signal, rejectWithValue, getState, dispatch } - ) => { - const state = getState() as any - const datasets = uniq(vesselGroup.vessels.flatMap((v) => v.dataset || [])) - const datasetId = datasets[0] - let dataset = selectDatasetById(datasetId)(state) - if (!dataset) { - const action = await dispatch(fetchDatasetByIdThunk(datasetId)) - if (fetchDatasetByIdThunk.fulfilled.match(action)) { - dataset = action.payload - } - } - if (vesselGroup.id && dataset) { - const datasetConfig: DataviewDatasetConfig = { - endpoint: EndpointId.VesselList, - datasetId: '', - params: [], - query: [ - { - id: 'vessel-groups', - value: vesselGroup.id, - }, - { - id: 'cache', - value: false, - }, - ], - } - try { - const url = resolveEndpoint(dataset, datasetConfig) - if (!url) { - console.warn('Missing search url') - return rejectWithValue({ - code: 0, - message: 'Missing search url', - }) - } - const vessels = await GFWAPI.fetch>(url, { - signal, - cache: 'reload', - }) - return vessels.entries - } catch (e: any) { - console.warn(e) - return rejectWithValue(parseAPIError(e)) - } - } else { - console.warn('No search datasets found') - return rejectWithValue({ - code: 0, - message: 'No search datasets found', - }) - } - }, - { - condition: (_, { getState }) => { - const workspaceVesselGroupsStatus = (getState() as VesselGroupSliceState).vesselGroups.search - .status - // Fetched already in progress, don't need to re-fetch - return workspaceVesselGroupsStatus !== AsyncReducerStatus.Loading - }, - } -) - export const fetchWorkspaceVesselGroupsThunk = createAsyncThunk( 'workspace-vessel-groups/fetch', async (ids: string[] = [], { signal, rejectWithValue, getState }) => { @@ -305,8 +64,7 @@ export const fetchWorkspaceVesselGroupsThunk = createAsyncThunk( }, { condition: (_, { getState }) => { - const workspaceVesselGroupsStatus = (getState() as VesselGroupSliceState).vesselGroups - .workspace.status + const workspaceVesselGroupsStatus = (getState() as VesselGroupSliceState).vesselGroups.status // Fetched already in progress, don't need to re-fetch return workspaceVesselGroupsStatus !== AsyncReducerStatus.Loading }, @@ -338,10 +96,6 @@ export const fetchVesselGroupsThunk = createAsyncThunk< } ) -const removeDuplicatedVesselGroupvessels = (vessels: VesselGroupVessel[]) => { - return uniqBy(vessels, (vessel) => [vessel.vesselId, vessel.dataset].join(',')) -} - export const fetchVesselGroupByIdThunk = createAsyncThunk( 'vessel-groups/fetchById', async (vesselGroupId: string) => { @@ -357,7 +111,7 @@ export const createVesselGroupThunk = createAsyncThunk( async (vesselGroupCreate: VesselGroupUpsert, { dispatch, getState }) => { const vesselGroupUpsert: VesselGroupUpsert = { ...vesselGroupCreate, - vessels: removeDuplicatedVesselGroupvessels(vesselGroupCreate.vessels || []), + vessels: prepareVesselGroupVesselsUpdate(vesselGroupCreate.vessels || []), } const saveVesselGroup: any = async (vesselGroup: VesselGroupUpsert, tries = 0) => { let vesselGroupUpdated: VesselGroup @@ -385,6 +139,7 @@ export const createVesselGroupThunk = createAsyncThunk( export type UpdateVesselGroupThunkParams = VesselGroupUpsert & { id: string + override?: boolean } export const updateVesselGroupThunk = createAsyncThunk( 'vessel-groups/update', @@ -392,7 +147,7 @@ export const updateVesselGroupThunk = createAsyncThunk( const { id, ...rest } = vesselGroupUpsert const vesselGroup: VesselGroupUpsert = { ...rest, - vessels: removeDuplicatedVesselGroupvessels(rest.vessels || []), + vessels: prepareVesselGroupVesselsUpdate(rest.vessels || []), } const vesselGroupUpdated = await GFWAPI.fetch(`/vessel-groups/${id}`, { method: 'PATCH', @@ -404,9 +159,12 @@ export const updateVesselGroupThunk = createAsyncThunk( export const updateVesselGroupVesselsThunk = createAsyncThunk( 'vessel-groups/update-vessels', - async ({ id, vessels = [] }: UpdateVesselGroupThunkParams, { getState, dispatch }) => { + async ( + { id, vessels = [], override = false }: UpdateVesselGroupThunkParams, + { getState, dispatch } + ) => { let vesselGroup = selectVesselGroupById(id)(getState() as any) - if (!vesselGroup) { + if (!override && !vesselGroup) { const action = await dispatch(fetchVesselGroupByIdThunk(id)) if (fetchVesselGroupByIdThunk.fulfilled.match(action) && action.payload) { vesselGroup = action.payload @@ -416,7 +174,9 @@ export const updateVesselGroupVesselsThunk = createAsyncThunk( return dispatch( updateVesselGroupThunk({ id: vesselGroup.id, - vessels: [...vesselGroup.vessels, ...vessels], + vessels: override + ? vessels + : uniqBy([...vesselGroup.vessels, ...vessels], (v) => v.vesselId), }) ) } @@ -448,82 +208,20 @@ export const { slice: vesselGroupsSlice, entityAdapter } = createAsyncSlice< name: 'vesselGroups', initialState, reducers: { - setVesselGroupsModalOpen: (state, action: PayloadAction) => { - state.isModalOpen = action.payload - }, - setVesselGroupSearchId: (state, action: PayloadAction) => { - state.search.id = action.payload - }, - resetVesselGroupStatus: (state) => { - state.status = AsyncReducerStatus.Idle - state.search.status = AsyncReducerStatus.Idle - }, - setVesselGroupSearchVessels: (state, action: PayloadAction) => { - state.search.vessels = action.payload - }, - setNewVesselGroupSearchVessels: (state, action: PayloadAction) => { - state.newSearchVessels = action.payload - }, - setVesselGroupVessels: (state, action: PayloadAction) => { - state.groupVessels = action.payload - }, - setVesselGroupEditId: (state, action: PayloadAction) => { - state.vesselGroupEditId = action.payload - }, - setVesselGroupConfirmationMode: (state, action: PayloadAction) => { - state.confirmationMode = action.payload - }, - setVesselGroupCurrentDataviewIds: (state, action: PayloadAction) => { - state.currentDataviewIds = action.payload - }, - resetVesselGroup: (state) => { - // Dont reset async reducer properties as it contains the list of existing vessel gruops - const { status, statusId, error, ids, currentRequestIds, entities } = state - return { ...initialState, ids, entities, status, statusId, error, currentRequestIds } + resetVesselGroup: () => { + return { ...initialState } }, }, + extraReducers(builder) { - builder.addCase(searchVesselGroupsVesselsThunk.pending, (state) => { - state.search.status = AsyncReducerStatus.Loading - state.search.vessels = null - }) - builder.addCase(searchVesselGroupsVesselsThunk.fulfilled, (state, action) => { - state.search.status = AsyncReducerStatus.Finished - state.search.vessels = action.payload - }) - builder.addCase(searchVesselGroupsVesselsThunk.rejected, (state, action) => { - if (action.error.message === 'Aborted') { - state.search.status = AsyncReducerStatus.Idle - } else { - state.search.status = AsyncReducerStatus.Error - state.search.error = action.payload as ParsedAPIError - } - }) - builder.addCase(getVesselInVesselGroupThunk.pending, (state) => { - state.search.status = AsyncReducerStatus.Loading - state.search.vessels = null - }) - builder.addCase(getVesselInVesselGroupThunk.fulfilled, (state, action) => { - state.search.status = AsyncReducerStatus.Finished - state.search.vessels = action.payload - }) - builder.addCase(getVesselInVesselGroupThunk.rejected, (state, action) => { - if (action.error.message === 'Aborted') { - state.search.status = AsyncReducerStatus.Idle - } else { - state.search.status = AsyncReducerStatus.Error - state.search.error = action.payload as ParsedAPIError - } - }) builder.addCase(fetchWorkspaceVesselGroupsThunk.pending, (state) => { state.workspace.status = AsyncReducerStatus.Loading - state.workspace.vesselGroups = null }) builder.addCase( fetchWorkspaceVesselGroupsThunk.fulfilled, (state, action: PayloadAction) => { state.workspace.status = AsyncReducerStatus.Finished - state.workspace.vesselGroups = action.payload + entityAdapter.addMany(state, action.payload) } ) builder.addCase(fetchWorkspaceVesselGroupsThunk.rejected, (state, action) => { @@ -544,19 +242,6 @@ export const { slice: vesselGroupsSlice, entityAdapter } = createAsyncSlice< }, }) -export const { - resetVesselGroup, - resetVesselGroupStatus, - setVesselGroupEditId, - setVesselGroupVessels, - setVesselGroupSearchId, - setVesselGroupsModalOpen, - setVesselGroupSearchVessels, - setNewVesselGroupSearchVessels, - setVesselGroupCurrentDataviewIds, - setVesselGroupConfirmationMode, -} = vesselGroupsSlice.actions - export const { selectAll: selectAllVesselGroups, selectById } = entityAdapter.getSelectors((state) => state.vesselGroups) @@ -564,32 +249,13 @@ export const selectVesselGroupById = memoize((id: string) => createSelector([(state: VesselGroupSliceState) => state], (state) => selectById(state, id)) ) -export const selectVesselGroupModalOpen = (state: VesselGroupSliceState) => - state.vesselGroups.isModalOpen export const selectVesselGroupsStatus = (state: VesselGroupSliceState) => state.vesselGroups.status export const selectWorkspaceVesselGroupsStatus = (state: VesselGroupSliceState) => state.vesselGroups.workspace.status + export const selectWorkspaceVesselGroupsError = (state: VesselGroupSliceState) => state.vesselGroups.workspace.error -export const selectVesselGroupsVessels = (state: VesselGroupSliceState) => - state.vesselGroups.groupVessels -export const selectWorkspaceVesselGroups = (state: VesselGroupSliceState) => - state.vesselGroups.workspace.vesselGroups -export const selectVesselGroupSearchId = (state: VesselGroupSliceState) => - state.vesselGroups.search.id -export const selectVesselGroupSearchStatus = (state: VesselGroupSliceState) => - state.vesselGroups.search.status -export const selectVesselGroupSearchVessels = (state: VesselGroupSliceState) => - state.vesselGroups.search.vessels -export const selectNewVesselGroupSearchVessels = (state: VesselGroupSliceState) => - state.vesselGroups.newSearchVessels export const selectVesselGroupsStatusId = (state: VesselGroupSliceState) => state.vesselGroups.statusId -export const selectCurrentDataviewIds = (state: VesselGroupSliceState) => - state.vesselGroups.currentDataviewIds -export const selectVesselGroupEditId = (state: VesselGroupSliceState) => - state.vesselGroups.vesselGroupEditId -export const selectVesselGroupConfirmationMode = (state: VesselGroupSliceState) => - state.vesselGroups.confirmationMode export default vesselGroupsSlice.reducer 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 52d87dd24e..7df6edd015 100644 --- a/apps/fishing-map/features/vessel-groups/vessel-groups.utils.ts +++ b/apps/fishing-map/features/vessel-groups/vessel-groups.utils.ts @@ -1,7 +1,139 @@ -import { VesselGroup } from '@globalfishingwatch/api-types' +import { uniq, uniqBy } from 'es-toolkit' +import { + IdentityVessel, + VesselGroup, + VesselGroupVessel, + VesselIdentitySourceEnum, +} from '@globalfishingwatch/api-types' import { PUBLIC_SUFIX } from 'data/config' +import { getVesselId, getVesselIdentities, getVesselProperty } from 'features/vessel/vessel.utils' +import { IdentityVesselData } from 'features/vessel/vessel.slice' +import { VESSEL_GROUPS_REPORT_RELEASE_DATE } from './vessel-groups.config' +import { VesselGroupVesselIdentity } from './vessel-groups-modal.slice' +import { AddVesselGroupVessel } from './vessel-groups.hooks' export const getVesselGroupLabel = (vesselGroup: VesselGroup) => { const isPrivate = !vesselGroup.id.endsWith(`-${PUBLIC_SUFIX}`) return `${isPrivate ? '🔒 ' : ''}${vesselGroup.name}` } + +export const isOutdatedVesselGroup = (vesselGroup: VesselGroup) => { + return vesselGroup?.updatedAt && vesselGroup?.updatedAt < VESSEL_GROUPS_REPORT_RELEASE_DATE +} + +export const getVesselGroupVesselsCount = (vesselGroup: VesselGroup) => { + return uniq(vesselGroup.vessels.map((v) => v.relationId || v.vesselId)).filter(Boolean).length +} + +export const removeDuplicatedVesselGroupvessels = (vessels: VesselGroupVesselIdentity[]) => { + return uniqBy(vessels, (vessel) => [vessel.vesselId, vessel.dataset].join(',')) +} +export const removeVesselGroupvesselIdentity = (vessels: VesselGroupVesselIdentity[]) => { + return vessels.map(({ identity, ...vessel }) => vessel) +} +export const prepareVesselGroupVesselsUpdate = (vessels: VesselGroupVesselIdentity[]) => { + return removeDuplicatedVesselGroupvessels(removeVesselGroupvesselIdentity(vessels)) +} + +export const getVesselGroupUniqVessels = ( + vessels: VesselGroupVesselIdentity[] | null +): VesselGroupVesselIdentity[] => { + if (!vessels) { + return [] + } + return uniqBy( + vessels.flatMap((vessel) => { + const identities = getVesselIdentities(vessel.identity!, { + identitySource: VesselIdentitySourceEnum.SelfReported, + }) + if (!identities.length) { + return [] + } + return identities.map((identity) => ({ + vesselId: identity.id, + dataset: identity.dataset as string, + relationId: vessel.relationId as string, + identity: (vessel.relationId === identity.id + ? vessel.identity + : undefined) as IdentityVessel, + })) + }), + (v) => v.vesselId + ) +} + +export const mergeVesselGroupVesselIdentities = ( + vesselGroupVessels: VesselGroupVessel[], + vesselIdentities: IdentityVessel[] +): VesselGroupVesselIdentity[] => { + return vesselGroupVessels + .flatMap((v) => { + const vesselIdentity = vesselIdentities.find((vesselIdentity) => { + const selftReportedIds = getVesselIdentities(vesselIdentity, { + identitySource: VesselIdentitySourceEnum.SelfReported, + })?.map((v) => v.id) + return selftReportedIds?.includes(v.vesselId) + }) + if (!vesselIdentity) { + return [] + } + const relationId = getVesselId(vesselIdentity) + return { + ...v, + relationId, + identity: (relationId === v.vesselId ? vesselIdentity : undefined) as IdentityVessel, + } + }) + .toSorted((a, b) => { + const aValue = getVesselProperty(a.identity!, 'shipname') + const bValue = getVesselProperty(b.identity!, 'shipname') + if (aValue === bValue) { + return 0 + } + return aValue > bValue ? 1 : -1 + }) +} + +export const flatVesselGroupSearchVessels = ( + vesselIdentities: IdentityVessel[] +): VesselGroupVesselIdentity[] => { + return vesselIdentities.flatMap((vessel) => { + const identities = getVesselIdentities(vessel, { + identitySource: VesselIdentitySourceEnum.SelfReported, + }) + if (!identities?.length) { + return [] + } + const relationId = getVesselId(vessel) + return identities.map((i) => ({ + vesselId: i.id, + dataset: i.dataset as string, + relationId, + identity: (relationId === i.id ? vessel : undefined) as IdentityVessel, + })) + }) +} + +export function parseVesselGroupVessels( + vessels: AddVesselGroupVessel[] +): VesselGroupVesselIdentity[] { + return vessels.map((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 + } + return vessel as VesselGroupVesselIdentity + }) +} diff --git a/apps/fishing-map/features/vessel/vessel.config.ts b/apps/fishing-map/features/vessel/vessel.config.ts index 45ce2cf80b..f0940510c9 100644 --- a/apps/fishing-map/features/vessel/vessel.config.ts +++ b/apps/fishing-map/features/vessel/vessel.config.ts @@ -7,6 +7,7 @@ import { VesselProfileState } from './vessel.types' export const DEFAULT_VESSEL_IDENTITY_DATASET = 'public-global-vessel-identity' export const DEFAULT_VESSEL_IDENTITY_VERSION = 'v3.0' export const DEFAULT_VESSEL_IDENTITY_ID = `${DEFAULT_VESSEL_IDENTITY_DATASET}:${DEFAULT_VESSEL_IDENTITY_VERSION}` +export const INCLUDES_RELATED_SELF_REPORTED_INFO_ID = 'POTENTIAL_RELATED_SELF_REPORTED_INFO' export const CACHE_FALSE_PARAM = { id: 'cache', value: 'false' } export const DEFAULT_VESSEL_STATE: VesselProfileState = { diff --git a/apps/fishing-map/features/vessel/vessel.slice.ts b/apps/fishing-map/features/vessel/vessel.slice.ts index 6354fc3572..657c104d17 100644 --- a/apps/fishing-map/features/vessel/vessel.slice.ts +++ b/apps/fishing-map/features/vessel/vessel.slice.ts @@ -6,12 +6,14 @@ import { ApiEvent, Dataset, DatasetTypes, + GearType, IdentityVessel, Resource, ResourceStatus, SelfReportedInfo, VesselCombinedSourcesInfo, VesselRegistryInfo, + VesselType, } from '@globalfishingwatch/api-types' import { setResource } from '@globalfishingwatch/dataviews-client' import { resolveEndpoint } from '@globalfishingwatch/datasets-client' @@ -42,12 +44,16 @@ export type VesselDataIdentity = (SelfReportedInfo | VesselRegistryInfo) & { identitySource: VesselIdentitySourceEnum combinedSourcesInfo?: VesselCombinedSourcesInfo positionsCounter?: number + dataset?: string + geartypes?: GearType[] + shiptypes?: VesselType[] } // Merges and plain all the identities of a vessel export type IdentityVesselData = { id: string identities: VesselDataIdentity[] dataset: Dataset + datasetId: string } & VesselInstanceDatasets & Pick< IdentityVessel, @@ -143,6 +149,7 @@ export const fetchVesselInfoThunk = createAsyncThunk( return { id: getVesselProperty(vessel, 'id'), dataset: dataset, + datasetId: dataset?.id, combinedSourcesInfo: vessel?.combinedSourcesInfo, registryOwners: vessel?.registryOwners, registryPublicAuthorizations: vessel?.registryPublicAuthorizations, diff --git a/apps/fishing-map/features/vessel/vessel.utils.ts b/apps/fishing-map/features/vessel/vessel.utils.ts index c9f0a30d7c..1ae5946751 100644 --- a/apps/fishing-map/features/vessel/vessel.utils.ts +++ b/apps/fishing-map/features/vessel/vessel.utils.ts @@ -18,6 +18,33 @@ import { IdentityVesselData, VesselDataIdentity } from 'features/vessel/vessel.s import { TimeRange } from 'features/timebar/timebar.slice' type GetVesselIdentityParams = { identityId?: string; identitySource?: VesselIdentitySourceEnum } + +const getVesselIdentitiesBySource = ( + vessel: IdentityVessel, + { identitySource } = {} as Pick +): VesselDataIdentity[] => { + if (!identitySource) { + return [] as VesselDataIdentity[] + } + return (vessel?.[identitySource] || []).map((identity) => { + const geartypes = getVesselCombinedSourceProperty(vessel, { + vesselId: identity.id, + property: 'geartypes', + })?.map((i) => i.name as GearType) + const shiptypes = getVesselCombinedSourceProperty(vessel, { + vesselId: identity.id, + property: 'shiptypes', + })?.map((i) => i.name as VesselType) + return { + ...identity, + identitySource, + geartypes, + shiptypes, + dataset: vessel.dataset, + } as VesselDataIdentity + }) +} + export const getVesselIdentities = ( vessel: IdentityVessel | IdentityVesselData, { identitySource } = {} as Pick @@ -25,18 +52,18 @@ export const getVesselIdentities = ( if (!vessel) { return [] as VesselDataIdentity[] } + const identities = (vessel as IdentityVesselData).identities?.length ? (vessel as IdentityVesselData).identities : [ - ...((vessel as IdentityVessel).registryInfo || []).map((i) => ({ - ...i, + ...getVesselIdentitiesBySource(vessel as IdentityVessel, { identitySource: VesselIdentitySourceEnum.Registry, - })), - ...((vessel as IdentityVessel).selfReportedInfo || []).map((i) => ({ - ...i, + }), + ...getVesselIdentitiesBySource(vessel as IdentityVessel, { identitySource: VesselIdentitySourceEnum.SelfReported, - })), + }), ].sort((a, b) => (a.transmissionDateTo > b.transmissionDateTo ? -1 : 1)) + return identitySource ? identities.filter((i) => i.identitySource === identitySource) : identities } diff --git a/apps/fishing-map/features/workspace/common/LayerFilters.tsx b/apps/fishing-map/features/workspace/common/LayerFilters.tsx index 73e2e92f7b..bd0bf9e0cd 100644 --- a/apps/fishing-map/features/workspace/common/LayerFilters.tsx +++ b/apps/fishing-map/features/workspace/common/LayerFilters.tsx @@ -31,10 +31,7 @@ import HistogramRangeFilter from 'features/workspace/environmental/HistogramRang import { useVesselGroupsOptions } from 'features/vessel-groups/vessel-groups.hooks' import { selectVessselGroupsAllowed } from 'features/vessel-groups/vessel-groups.selectors' import { useAppDispatch } from 'features/app/app.hooks' -import { - setVesselGroupCurrentDataviewIds, - setVesselGroupsModalOpen, -} from 'features/vessel-groups/vessel-groups.slice' +import { setVesselGroupsModalOpen } from 'features/vessel-groups/vessel-groups-modal.slice' import { trackEvent, TrackCategory } from 'features/app/analytics.hooks' import { listAsSentence } from 'utils/shared' import UserGuideLink from 'features/help/UserGuideLink' @@ -252,7 +249,6 @@ function LayerFilters({ }: OnSelectFilterArgs) => { if ((selection as MultiSelectOption)?.id === VESSEL_GROUPS_MODAL_ID) { dispatch(setVesselGroupsModalOpen(true)) - dispatch(setVesselGroupCurrentDataviewIds([dataview.id])) return } let filterValues: number | string[] diff --git a/apps/fishing-map/features/workspace/vessel-groups/VesselGroupsLayerPanel.tsx b/apps/fishing-map/features/workspace/vessel-groups/VesselGroupsLayerPanel.tsx index c051861529..87671948e1 100644 --- a/apps/fishing-map/features/workspace/vessel-groups/VesselGroupsLayerPanel.tsx +++ b/apps/fishing-map/features/workspace/vessel-groups/VesselGroupsLayerPanel.tsx @@ -13,10 +13,10 @@ import { formatInfoField } from 'utils/info' import VesselGroupReportLink from 'features/reports/vessel-groups/VesselGroupReportLink' import { useAppDispatch } from 'features/app/app.hooks' import { - setNewVesselGroupSearchVessels, + setVesselGroupModalVessels, setVesselGroupEditId, setVesselGroupsModalOpen, -} from 'features/vessel-groups/vessel-groups.slice' +} from 'features/vessel-groups/vessel-groups-modal.slice' import { selectIsGFWUser } from 'features/user/selectors/user.selectors' import { selectReadOnly } from 'features/app/selectors/app.selectors' import Color from '../common/Color' @@ -66,7 +66,7 @@ function VesselGroupLayerPanel({ const onEditClick = () => { if (vesselGroup && (vesselGroup?.id || !vesselGroup?.vessels?.length)) { dispatch(setVesselGroupEditId(vesselGroup.id)) - dispatch(setNewVesselGroupSearchVessels(vesselGroup.vessels)) + dispatch(setVesselGroupModalVessels(vesselGroup.vessels)) dispatch(setVesselGroupsModalOpen(true)) } } diff --git a/apps/fishing-map/features/workspace/vessel-groups/VesselGroupsSection.tsx b/apps/fishing-map/features/workspace/vessel-groups/VesselGroupsSection.tsx index c26039d785..cc3a32d294 100644 --- a/apps/fishing-map/features/workspace/vessel-groups/VesselGroupsSection.tsx +++ b/apps/fishing-map/features/workspace/vessel-groups/VesselGroupsSection.tsx @@ -13,12 +13,12 @@ import { useAppDispatch } from 'features/app/app.hooks' import { selectVesselGroupsStatusId, selectWorkspaceVesselGroupsStatus, - setVesselGroupsModalOpen, } from 'features/vessel-groups/vessel-groups.slice' import UserLoggedIconButton from 'features/user/UserLoggedIconButton' import { NEW_VESSEL_GROUP_ID } from 'features/vessel-groups/vessel-groups.hooks' import { AsyncReducerStatus } from 'utils/async-slice' import { getVesselGroupDataviewInstance } from 'features/reports/vessel-groups/vessel-group-report.dataviews' +import { setVesselGroupsModalOpen } from 'features/vessel-groups/vessel-groups-modal.slice' import VesselGroupLayerPanel from './VesselGroupsLayerPanel' function VesselGroupSection(): React.ReactElement { diff --git a/apps/fishing-map/features/workspace/vessels/VesselsSection.tsx b/apps/fishing-map/features/workspace/vessels/VesselsSection.tsx index a7df1e4709..72c44de3a8 100644 --- a/apps/fishing-map/features/workspace/vessels/VesselsSection.tsx +++ b/apps/fishing-map/features/workspace/vessels/VesselsSection.tsx @@ -29,14 +29,14 @@ import VesselGroupAddButton from 'features/vessel-groups/VesselGroupAddButton' import { selectWorkspaceVessselGroupsIds } from 'features/vessel-groups/vessel-groups.selectors' import { NEW_VESSEL_GROUP_ID } from 'features/vessel-groups/vessel-groups.hooks' import UserLoggedIconButton from 'features/user/UserLoggedIconButton' -import { - selectVesselGroupsStatus, - setVesselGroupConfirmationMode, -} from 'features/vessel-groups/vessel-groups.slice' +import { selectVesselGroupsStatus } from 'features/vessel-groups/vessel-groups.slice' import { AsyncReducerStatus } from 'utils/async-slice' import { useAppDispatch } from 'features/app/app.hooks' import { getVesselGroupDataviewInstance } from 'features/reports/vessel-groups/vessel-group-report.dataviews' import { selectActiveVesselsDataviews } from 'features/dataviews/selectors/dataviews.categories.selectors' +import { setVesselGroupConfirmationMode } from 'features/vessel-groups/vessel-groups-modal.slice' +import { IdentityVesselData } from 'features/vessel/vessel.slice' +import { getVesselId, getVesselIdentities } from 'features/vessel/vessel.utils' import VesselEventsLegend from './VesselEventsLegend' import VesselLayerPanel from './VesselLayerPanel' import VesselsFromPositions from './VesselsFromPositions' @@ -147,7 +147,13 @@ function VesselsSection(): React.ReactElement { ) const vesselsToVesselGroup = areVesselsLoading ? [] - : vesselResources.map((resource) => resource.data) + : vesselResources.map(({ data }) => { + return { + id: getVesselId(data), + identities: getVesselIdentities(data), + datasetId: data.dataset, + } as IdentityVesselData + }) return (
    diff --git a/apps/fishing-map/public/locales/source/translations.json b/apps/fishing-map/public/locales/source/translations.json index 9a39ddcd3d..1c5766d219 100644 --- a/apps/fishing-map/public/locales/source/translations.json +++ b/apps/fishing-map/public/locales/source/translations.json @@ -1013,6 +1013,7 @@ "addToWorkspace": "Add vessel group to workspace", "addVessels": "Add vessels to vessel group", "addVisibleVessels": "Add visible vessels to vessel group", + "clickToUpdate": "Click to migrate your vessel group to latest available data", "confirmAbort": "You will lose any changes made in this vessel group. Are you sure?", "confirmRemove": "Are you sure you want to permanently delete this vessel group?", "createNewGroup": "Create new group", diff --git a/apps/fishing-map/reducers.ts b/apps/fishing-map/reducers.ts index 5cf15acb8a..cbd25285ff 100644 --- a/apps/fishing-map/reducers.ts +++ b/apps/fishing-map/reducers.ts @@ -27,6 +27,7 @@ import timebarReducer from 'features/timebar/timebar.slice' import titleReducer from 'routes/title.reducer' import userReducer from 'features/user/user.slice' import vesselGroupReportReducer from 'features/reports/vessel-groups/vessel-group-report.slice' +import vesselGroupsModalReducer from 'features/vessel-groups/vessel-groups-modal.slice' import vesselGroupsReducer from 'features/vessel-groups/vessel-groups.slice' import vesselReducer from 'features/vessel/vessel.slice' import workspaceReducer from 'features/workspace/workspace.slice' @@ -62,6 +63,7 @@ export const rootReducer = combineReducers({ user: userReducer, vessel: vesselReducer, vesselGroupReport: vesselGroupReportReducer, + vesselGroupModal: vesselGroupsModalReducer, vesselGroups: vesselGroupsReducer, workspace: workspaceReducer, workspaces: workspacesReducer, diff --git a/libs/api-types/src/vesselGroups.ts b/libs/api-types/src/vesselGroups.ts index f1481a1589..36a62890c8 100644 --- a/libs/api-types/src/vesselGroups.ts +++ b/libs/api-types/src/vesselGroups.ts @@ -1,6 +1,7 @@ export interface VesselGroupVessel { dataset: string vesselId: string + relationId: string flag?: string vesselType?: string } @@ -13,6 +14,7 @@ export interface VesselGroup { ownerId?: number ownerType?: string createdAt?: string + updatedAt?: string } export type VesselGroupUpsert = Partial>