diff --git a/packages/frontend/src/api/useDialogs.tsx b/packages/frontend/src/api/useDialogs.tsx index 66b6edb66..6d541b8cd 100644 --- a/packages/frontend/src/api/useDialogs.tsx +++ b/packages/frontend/src/api/useDialogs.tsx @@ -7,7 +7,7 @@ import { import { useEffect, useState } from 'react'; import { useQuery } from 'react-query'; import { useDebounce } from 'use-debounce'; -import type { InboxItemMetaField } from '../components/index.ts'; +import type { InboxItemMetaField } from '../components'; import { i18n } from '../i18n/config.ts'; import { type FormatFunction, useFormat } from '../i18n/useDateFnsLocale.tsx'; import type { InboxItemInput } from '../pages/Inbox/Inbox.tsx'; diff --git a/packages/frontend/src/api/useParties.ts b/packages/frontend/src/api/useParties.ts index a8dc4886d..92548aa44 100644 --- a/packages/frontend/src/api/useParties.ts +++ b/packages/frontend/src/api/useParties.ts @@ -1,47 +1,58 @@ import type { PartiesQuery, PartyFieldsFragment } from 'bff-types-generated'; import { useQuery, useQueryClient } from 'react-query'; -import { toTitleCase } from '../profile/name.ts'; +import { toTitleCase } from '../profile'; import { graphQLSDK } from './queries.ts'; interface UsePartiesOutput { parties: PartyFieldsFragment[]; + deletedParties: PartyFieldsFragment[]; isSuccess: boolean; isLoading: boolean; selectedParties: PartyFieldsFragment[]; + selectedPartyIds: string[]; setSelectedParties: (parties: PartyFieldsFragment[]) => void; setSelectedPartyIds: (parties: string[]) => void; currentEndUser: PartyFieldsFragment | undefined; + allOrganizationsSelected: boolean; +} + +interface PartiesResult { + parties: PartyFieldsFragment[]; + deletedParties: PartyFieldsFragment[]; } const fetchParties = (): Promise => graphQLSDK.parties(); export const useParties = (): UsePartiesOutput => { const queryClient = useQueryClient(); - const { data, isLoading, isSuccess } = useQuery( + const { data, isLoading, isSuccess } = useQuery( 'parties', async () => { const response = await fetchParties(); + const partiesWithNormalizedNames = + response.parties.map((party) => ({ + ...party, + name: toTitleCase(party.name), + })) ?? []; return { - parties: ( - response.parties.map((party) => ({ - ...party, - name: toTitleCase(party.name), - })) ?? [] - ).filter((party) => !party.isDeleted), + parties: partiesWithNormalizedNames.filter((party) => !party.isDeleted), + deletedParties: partiesWithNormalizedNames.filter((party) => party.isDeleted), }; }, { - onSuccess: (data) => { - if (!getSelectedParties() && data.parties && data.parties.length > 0) { + onSuccess: (data: PartiesResult) => { + if (!getSelectedParties().length && data?.parties?.length > 0) { const currentEndUser = data.parties.find((party) => party.isCurrentEndUser); if (currentEndUser) { setSelectedParties([currentEndUser]); + } else { + console.warn('No current end user found, unable to select default parties.'); } } }, }, ); - const getSelectedParties = () => queryClient.getQueryData('selectedParties'); + const getSelectedParties = () => queryClient.getQueryData('selectedParties') ?? []; const setSelectedParties = (parties: PartyFieldsFragment[] | null) => { queryClient.setQueryData('selectedParties', parties); }; @@ -53,10 +64,13 @@ export const useParties = (): UsePartiesOutput => { return { isLoading, isSuccess, - parties: data?.parties ?? ([] as PartyFieldsFragment[]), - selectedParties: getSelectedParties() ?? ([] as PartyFieldsFragment[]), + selectedParties: getSelectedParties(), + selectedPartyIds: getSelectedParties().map((party) => party.party) ?? [], setSelectedParties, setSelectedPartyIds, + parties: data?.parties ?? [], currentEndUser: data?.parties.find((party) => party.isCurrentEndUser), + deletedParties: data?.deletedParties ?? [], + allOrganizationsSelected: getSelectedParties().every((party) => party.partyType === 'Organization'), }; }; diff --git a/packages/frontend/src/components/GlobalMenuBar/NavigationDropdownSubMenuProfile.tsx b/packages/frontend/src/components/GlobalMenuBar/NavigationDropdownSubMenuProfile.tsx index 70cf13945..0a1c9c78a 100644 --- a/packages/frontend/src/components/GlobalMenuBar/NavigationDropdownSubMenuProfile.tsx +++ b/packages/frontend/src/components/GlobalMenuBar/NavigationDropdownSubMenuProfile.tsx @@ -1,18 +1,15 @@ -import { Search } from '@digdir/designsystemet-react'; import { ArrowLeftIcon } from '@navikt/aksel-icons'; -import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useParties } from '../../api/useParties.ts'; import { HorizontalLine } from '../HorizontalLine'; import { MenuItem } from '../MenuBar'; -import { PartyList } from '../PartyDropdown/PartyList.tsx'; +import { PartyListContainer } from '../PartyDropdown/PartyListContainer.tsx'; import { MenuLogoutButton } from './MenuLogoutButton.tsx'; import type { DropdownSubMenuProps } from './NavigationDropdownSubMenu.tsx'; import styles from './navigationDropdownMenu.module.css'; export const NavigationDropdownSubMenuProfile: React.FC = ({ onClose, onBack }) => { const { t } = useTranslation(); - const [searchValue, setSearchValue] = useState(''); const { parties } = useParties(); if (!parties.length) { return null; @@ -39,22 +36,7 @@ export const NavigationDropdownSubMenuProfile: React.FC = isWhiteBackground /> - { - setSearchValue(e.target.value); - }} - value={searchValue} - onClear={() => setSearchValue('')} - /> - } - /> - + diff --git a/packages/frontend/src/components/GlobalMenuBar/navigationDropdownMenu.module.css b/packages/frontend/src/components/GlobalMenuBar/navigationDropdownMenu.module.css index debebdcc1..3739c41fe 100644 --- a/packages/frontend/src/components/GlobalMenuBar/navigationDropdownMenu.module.css +++ b/packages/frontend/src/components/GlobalMenuBar/navigationDropdownMenu.module.css @@ -20,12 +20,13 @@ .menuList { background: #fff; - overflow: visible; list-style-type: none; padding: 0; margin: 0; border-radius: 0.5rem; z-index: 1001; + max-height: 600px; + overflow: auto; } @media screen and (max-width: 1024px) { @@ -37,6 +38,8 @@ top: 10rem; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); z-index: 1002; + overflow: auto; + height: calc(100% - 12rem); } .menuList { diff --git a/packages/frontend/src/components/Header/SearchBar.tsx b/packages/frontend/src/components/Header/SearchBar.tsx index 858800202..0f7b8b5ea 100644 --- a/packages/frontend/src/components/Header/SearchBar.tsx +++ b/packages/frontend/src/components/Header/SearchBar.tsx @@ -70,7 +70,7 @@ export const SearchBar: React.FC = () => { }} aria-label={t('header.searchPlaceholder')} placeholder={t('header.searchPlaceholder')} - className={cx(styles.searchInput)} + className={styles.searchInput} onChange={(e) => { setSearchValue(e.target.value); }} diff --git a/packages/frontend/src/components/Header/SearchDropdown.tsx b/packages/frontend/src/components/Header/SearchDropdown.tsx index def977e51..8287cb480 100644 --- a/packages/frontend/src/components/Header/SearchDropdown.tsx +++ b/packages/frontend/src/components/Header/SearchDropdown.tsx @@ -93,11 +93,7 @@ export const SearchDropdown: React.FC = ({ showDropdownMenu ...(filters?.length && { filters: JSON.stringify(filters) }), }); return ( - + {search.name ? (
diff --git a/packages/frontend/src/components/Header/search.module.css b/packages/frontend/src/components/Header/search.module.css index f7ffc238c..159d57d0d 100644 --- a/packages/frontend/src/components/Header/search.module.css +++ b/packages/frontend/src/components/Header/search.module.css @@ -29,7 +29,7 @@ border-radius: 6px 6px 0 0; } -input { +.searchInput { color: black; font-size: 1.125rem; font-style: normal; @@ -40,7 +40,7 @@ input { align-self: flex-start; } -input:focus-visible { +.searchInput:focus-visible { outline: none; } @@ -237,7 +237,6 @@ input:focus-visible { display: flex; flex-direction: row; justify-content: space-between; - /* margin: 0 1rem; */ } .rightContent { diff --git a/packages/frontend/src/components/MenuBar/MenuItem.tsx b/packages/frontend/src/components/MenuBar/MenuItem.tsx index 350ffac8d..e4b3d0ee6 100644 --- a/packages/frontend/src/components/MenuBar/MenuItem.tsx +++ b/packages/frontend/src/components/MenuBar/MenuItem.tsx @@ -21,25 +21,25 @@ interface MenuItem { rightContent?: React.ReactNode; useProfiledHover?: boolean; largeText?: boolean; - classNames?: string; + className?: string; disabled?: boolean; } const MenuItem = (props: MenuItem) => { - const { path, onClick, isExternalLink, leftContent, rightContent, classNames } = props; + const { path, onClick, isExternalLink, leftContent, rightContent, className } = props; const content = ; if (path) { return ( -
  • {content}
  • +
  • {content}
  • ); } if (onClick) { return ( -
  • +
  • {content}
  • ); diff --git a/packages/frontend/src/components/MenuBar/menuItem.module.css b/packages/frontend/src/components/MenuBar/menuItem.module.css index 9446a1991..0828c7899 100644 --- a/packages/frontend/src/components/MenuBar/menuItem.module.css +++ b/packages/frontend/src/components/MenuBar/menuItem.module.css @@ -24,6 +24,7 @@ .liItem { margin-bottom: 0.5rem; + list-style: none; } .isLink { @@ -39,6 +40,12 @@ width: 100%; } +.rightContent { + display: flex; + align-items: center; + grid-area: right; +} + .displayText { margin-left: 10px; color: var(--black-100, #000); diff --git a/packages/frontend/src/components/PageLayout/PageLayout.tsx b/packages/frontend/src/components/PageLayout/PageLayout.tsx index 119d3523a..eed851117 100644 --- a/packages/frontend/src/components/PageLayout/PageLayout.tsx +++ b/packages/frontend/src/components/PageLayout/PageLayout.tsx @@ -1,6 +1,7 @@ import cx from 'classnames'; import type React from 'react'; import { memo, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { useQueryClient } from 'react-query'; import { Outlet, useLocation, useSearchParams } from 'react-router-dom'; import { Footer, Header, type ItemPerViewCount, Sidebar } from '..'; @@ -33,11 +34,12 @@ interface PageLayoutContentProps { const PageLayoutContent: React.FC = memo( ({ name, companyName, isCompany, notificationCount }) => { + const { t } = useTranslation(); const { inSelectionMode } = useSelectedDialogs(); const { isTabletOrSmaller } = useWindowSize(); const showSidebar = !isTabletOrSmaller && !inSelectionMode; - const { selectedParties } = useParties(); - const { currentPartySavedSearches } = useSavedSearches(selectedParties); + const { selectedPartyIds, selectedParties, allOrganizationsSelected } = useParties(); + const { currentPartySavedSearches } = useSavedSearches(selectedPartyIds); const { dialogsByView } = useDialogs(selectedParties); const itemsPerViewCount = { inbox: dialogsByView.inbox.length, @@ -48,9 +50,11 @@ const PageLayoutContent: React.FC = memo( deleted: 0, } as ItemPerViewCount; + const usedCompanyName = allOrganizationsSelected ? t('parties.labels.all_organizations') : companyName; + return ( <> -
    +
    {showSidebar && } diff --git a/packages/frontend/src/components/PageLayout/pageLayout.module.css b/packages/frontend/src/components/PageLayout/pageLayout.module.css index efbfd1e15..5432b7327 100644 --- a/packages/frontend/src/components/PageLayout/pageLayout.module.css +++ b/packages/frontend/src/components/PageLayout/pageLayout.module.css @@ -11,8 +11,9 @@ } .background { - background: var(--background-color); min-height: 100vh; + background: var(--background-color); + overflow: auto; } .inSelectionMode.background { @@ -25,7 +26,6 @@ .pageLayout > main { grid-area: main; - overflow: auto; padding: 0; width: 100%; margin-right: auto; diff --git a/packages/frontend/src/components/PartyDropdown/PartyDropdown.tsx b/packages/frontend/src/components/PartyDropdown/PartyDropdown.tsx index 82a97a3dd..d976a5c63 100644 --- a/packages/frontend/src/components/PartyDropdown/PartyDropdown.tsx +++ b/packages/frontend/src/components/PartyDropdown/PartyDropdown.tsx @@ -6,7 +6,7 @@ import { Backdrop } from '../Backdrop'; import { DropdownList, DropdownMobileHeader } from '../DropdownMenu'; import { ProfileButton } from '../ProfileButton'; import type { SideBarView } from '../Sidebar'; -import { PartyList } from './PartyList.tsx'; +import { PartyListContainer } from './PartyListContainer.tsx'; interface PartyDropdownRef { openPartyDropdown: () => void; @@ -20,7 +20,7 @@ export const PartyDropdown = forwardRef((props: PartyDropdownProps, ref: Ref(false); const { t } = useTranslation(); - const { selectedParties } = useParties(); + const { selectedParties, allOrganizationsSelected, parties } = useParties(); useImperativeHandle(ref, () => ({ openPartyDropdown: () => { @@ -30,8 +30,10 @@ export const PartyDropdown = forwardRef((props: PartyDropdownProps, ref: Ref - setIsMenuOpen(!isMenuOpen)} color="neutral"> - {selectedParties?.[0]?.name ?? t('partyDropdown.selectParty')} + setIsMenuOpen(!isMenuOpen)} color="neutral" disabled={!parties.length}> + {allOrganizationsSelected + ? t('parties.labels.all_organizations') + : selectedParties?.[0]?.name ?? t('partyDropdown.selectParty')} @@ -40,9 +42,8 @@ export const PartyDropdown = forwardRef((props: PartyDropdownProps, ref: Ref - + setIsMenuOpen(false)} counterContext={counterContext} /> - setIsMenuOpen(false)} />
    ); diff --git a/packages/frontend/src/components/PartyDropdown/PartyList.tsx b/packages/frontend/src/components/PartyDropdown/PartyList.tsx index 0d239858c..0cd48ed99 100644 --- a/packages/frontend/src/components/PartyDropdown/PartyList.tsx +++ b/packages/frontend/src/components/PartyDropdown/PartyList.tsx @@ -1,50 +1,93 @@ -import { Fragment, useMemo } from 'react'; -import { useQueryClient } from 'react-query'; -import { useDialogs } from '../../api/useDialogs.tsx'; -import { useParties } from '../../api/useParties.ts'; -import { useSavedSearches } from '../../pages/SavedSearches/useSavedSearches.ts'; +import { Search } from '@digdir/designsystemet-react'; +import { Fragment, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { Avatar } from '../Avatar'; import { HorizontalLine } from '../HorizontalLine'; import { MenuGroupHeader, MenuItem } from '../MenuBar'; -import type { SideBarView } from '../Sidebar'; -import { type MergedPartyGroup, groupParties, mergeParties } from './mergeParties.ts'; +import type { MergedPartyGroup } from './mergePartiesByName.ts'; import styles from './partyDropdown.module.css'; interface PartyListProps { - onOpenMenu: (value: boolean) => void; - counterContext?: SideBarView; + optionsGroups: MergedPartyGroup; + selectedPartyIds: string[]; + onSelect: (ids: string[]) => void; + showSearchFilter?: boolean; } -export const PartyList = ({ onOpenMenu, counterContext = 'inbox' }: PartyListProps) => { - const { parties, setSelectedPartyIds, selectedParties } = useParties(); - const { dialogsByView } = useDialogs(parties); - const queryClient = useQueryClient(); - const { savedSearches } = useSavedSearches(selectedParties); +/** + * Component for rendering a list of parties + * This component is only responsible for rendering the list of parties and certain business logic for grouping and filtering + * @param optionsGroups - The groups of parties to render + * @param selectedPartyIds - The ids of the selected parties + * @param onSelect - The function to call when a party is selected + * @param showSearchFilter - Whether to show the search filter + * @returns A list of parties + * @example + * + * */ - const optionsGroups: MergedPartyGroup = useMemo(() => { - return groupParties( - parties.map((party) => - mergeParties(party, dialogsByView[counterContext as keyof typeof dialogsByView], savedSearches, counterContext), - ), - ); - }, [parties, dialogsByView, savedSearches, counterContext]); +export const PartyList = ({ optionsGroups, selectedPartyIds, onSelect, showSearchFilter }: PartyListProps) => { + const [filterString, setFilterString] = useState(''); + const { t } = useTranslation(); + const filteredOptionsGroups = useMemo(() => { + if (!filterString) { + return optionsGroups; + } + const allParties = Object.values(optionsGroups).flatMap((group) => group.parties); + const filteredParties = allParties.filter(({ label }) => label.toLowerCase().includes(filterString.toLowerCase())); + + return { + Filtered: { + title: t('partyDropdown.filter_results', { count: filteredParties.length }), + parties: filteredParties, + isSearchResults: true, + }, + }; + }, [optionsGroups, filterString]); return ( <> - {Object.entries(optionsGroups) - .filter(([_, group]) => group.parties.length > 0) + {showSearchFilter && ( + { + setFilterString(e.target.value); + }} + value={filterString} + onClear={() => setFilterString('')} + /> + } + /> + )} + {Object.entries(filteredOptionsGroups) + .filter(([_, group]) => group.isSearchResults || group.parties.length > 0) .map(([key, group], index, list) => { const isLastGroup = index === list.length - 1; - return ( {group.parties.map((option) => { const companyName = option.isCompany ? option.label : ''; + const isSelected = !!( + selectedPartyIds.length && + selectedPartyIds.length === option.onSelectValues.length && + selectedPartyIds.every((urn) => option.onSelectValues.includes(urn)) + ); return ( option.onSelectValues.includes(party.party))} + className={styles.partyListItem} + isActive={isSelected} leftContent={ @@ -53,10 +96,7 @@ export const PartyList = ({ onOpenMenu, counterContext = 'inbox' }: PartyListPro } count={option.count} onClick={() => { - setSelectedPartyIds(option.onSelectValues); - void queryClient.invalidateQueries(['dialogs']); - void queryClient.invalidateQueries(['savedSearches']); - onOpenMenu(false); + onSelect(option.onSelectValues); }} /> {!isLastGroup && } diff --git a/packages/frontend/src/components/PartyDropdown/PartyListContainer.tsx b/packages/frontend/src/components/PartyDropdown/PartyListContainer.tsx new file mode 100644 index 000000000..c4df8f98a --- /dev/null +++ b/packages/frontend/src/components/PartyDropdown/PartyListContainer.tsx @@ -0,0 +1,59 @@ +import { useMemo } from 'react'; +import { useQueryClient } from 'react-query'; +import { useDialogs } from '../../api/useDialogs.tsx'; +import { useParties } from '../../api/useParties.ts'; +import { useSavedSearches } from '../../pages/SavedSearches/useSavedSearches.ts'; +import type { SideBarView } from '../Sidebar'; +import { PartyList } from './PartyList.tsx'; +import { type MergedPartyGroup, getOptionsGroups } from './mergePartiesByName.ts'; + +interface PartyListAdapterProps { + counterContext?: SideBarView; + children: (props: { + optionsGroups: MergedPartyGroup; + selectedPartyIds: string[]; + onSelect: (values: string[]) => void; + showSearchFilter: boolean; + }) => JSX.Element; +} + +interface PartyListContainerProps { + counterContext?: SideBarView; + onSelect?: () => void; +} + +const PartyListAdapter = ({ counterContext = 'inbox', children }: PartyListAdapterProps) => { + const queryClient = useQueryClient(); + const { parties, setSelectedPartyIds, selectedPartyIds } = useParties(); + const { dialogsByView } = useDialogs(parties); + const { savedSearches } = useSavedSearches(selectedPartyIds); + const showSearchFilter = parties.length > 10; + + const onSelect = (ids: string[]) => { + setSelectedPartyIds(ids); + void queryClient.invalidateQueries(['dialogs']); + void queryClient.invalidateQueries(['savedSearches']); + }; + + const optionsGroups: MergedPartyGroup = useMemo(() => { + return getOptionsGroups(parties, dialogsByView, savedSearches, counterContext); + }, [parties, dialogsByView, savedSearches, counterContext]); + + return children({ optionsGroups, selectedPartyIds, onSelect, showSearchFilter }); +}; + +export const PartyListContainer = ({ counterContext, onSelect }: PartyListContainerProps) => ( + + {({ optionsGroups, selectedPartyIds, onSelect: onAdapterSelect, showSearchFilter }) => ( + { + onAdapterSelect(ids); + onSelect?.(); + }} + showSearchFilter={showSearchFilter} + /> + )} + +); diff --git a/packages/frontend/src/components/PartyDropdown/index.ts b/packages/frontend/src/components/PartyDropdown/index.ts index 04037e8fb..5a3803d91 100644 --- a/packages/frontend/src/components/PartyDropdown/index.ts +++ b/packages/frontend/src/components/PartyDropdown/index.ts @@ -1 +1,2 @@ export { PartyDropdown } from './PartyDropdown'; +export { PartyList } from './PartyList'; diff --git a/packages/frontend/src/components/PartyDropdown/mergeParties.spec.ts b/packages/frontend/src/components/PartyDropdown/mergeParties.spec.ts index d8e74444e..4fe930b69 100644 --- a/packages/frontend/src/components/PartyDropdown/mergeParties.spec.ts +++ b/packages/frontend/src/components/PartyDropdown/mergeParties.spec.ts @@ -1,6 +1,6 @@ import type { PartyFieldsFragment } from 'bff-types-generated'; import { describe, expect, it } from 'vitest'; -import { mergeParties } from './mergeParties.ts'; +import { mergePartiesByName } from './mergePartiesByName.ts'; describe('mergeParties', () => { it('should correctly merge subparties with the same name as the parent party', () => { @@ -16,7 +16,7 @@ describe('mergeParties', () => { const dialogs = [{ party: 'party1' }, { party: 'subParty1' }, { party: 'subParty2' }]; - const result = mergeParties(party, dialogs); + const result = mergePartiesByName(party, dialogs); expect(result).toEqual({ label: 'Acme Corp', isCompany: true, @@ -36,7 +36,7 @@ describe('mergeParties', () => { const dialogs = [{ party: 'party2' }]; - const result = mergeParties(party, dialogs); + const result = mergePartiesByName(party, dialogs); expect(result).toEqual({ label: 'Solo Corp', isCompany: true, @@ -57,7 +57,7 @@ describe('mergeParties', () => { const dialogs = [{ party: 'party3' }, { party: 'subParty3' }]; - const result = mergeParties(party, dialogs); + const result = mergePartiesByName(party, dialogs); expect(result).toEqual({ label: 'Main Corp', isCompany: true, diff --git a/packages/frontend/src/components/PartyDropdown/mergeParties.ts b/packages/frontend/src/components/PartyDropdown/mergeParties.ts deleted file mode 100644 index 4e5dacedd..000000000 --- a/packages/frontend/src/components/PartyDropdown/mergeParties.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { PartyFieldsFragment, SavedSearchesFieldsFragment } from 'bff-types-generated'; -import { filterSavedSearches } from '../../pages/SavedSearches/useSavedSearches.ts'; -import type { SideBarView } from '../Sidebar'; - -type Dialog = { - party: string; -}; - -type PartyGroupType = 'End_user' | 'Other_people' | 'Organizations'; - -export type MergedPartyGroup = { - [key in PartyGroupType]: { - title: string; - parties: MergedParty[]; - }; -}; - -type MergedParty = { - label: string; - isCompany: boolean; - value: string; - onSelectValues: string[]; - count: number; - isCurrentEndUser: boolean; -}; - -export function groupParties(mergedParties: MergedParty[]): MergedPartyGroup { - if (mergedParties.length >= 3) { - return { - Other_people: { title: '', parties: mergedParties }, - End_user: { title: '', parties: [] }, - Organizations: { title: '', parties: [] }, - }; - } - return mergedParties.reduce( - (acc, party) => { - if (party.isCurrentEndUser) { - acc.End_user.parties.push(party); - } else if (party.isCompany) { - acc.Organizations.parties.push(party); - } else { - acc.Other_people.parties.push(party); - } - return acc; - }, - { - End_user: { title: '', parties: [] }, - Other_people: { title: 'parties.labels.persons', parties: [] }, - Organizations: { title: 'parties.labels.organizations', parties: [] }, - }, - ); -} - -export function mergeParties( - party: PartyFieldsFragment, - dialogs: Dialog[], - savedSearches?: SavedSearchesFieldsFragment[], - counterContext?: SideBarView, -): MergedParty { - let count = 0; - if (counterContext === 'saved-searches') { - count = filterSavedSearches(savedSearches ?? [], [party]).length ?? 0; - } else { - count = dialogs?.filter((dialogs) => dialogs?.party === party.party).length ?? 0; - } - - const mergedParties = party.subParties?.reduce( - (acc, subParty) => { - if (subParty.name === party.name) { - acc.push(subParty.party); - } else { - acc.push(subParty.party); - } - return acc; - }, - [party.party], - ) ?? [party.party]; - - return { - label: party.name, - isCompany: party.partyType === 'Organization', - value: party.party, - onSelectValues: mergedParties, - count, - isCurrentEndUser: party.isCurrentEndUser ?? false, - }; -} diff --git a/packages/frontend/src/components/PartyDropdown/mergePartiesByName.ts b/packages/frontend/src/components/PartyDropdown/mergePartiesByName.ts new file mode 100644 index 000000000..1c093ec55 --- /dev/null +++ b/packages/frontend/src/components/PartyDropdown/mergePartiesByName.ts @@ -0,0 +1,129 @@ +import type { PartyFieldsFragment, SavedSearchesFieldsFragment } from 'bff-types-generated'; +import { t } from 'i18next'; +import type { InboxItemInput } from '../../pages/Inbox/Inbox.tsx'; +import { filterSavedSearches } from '../../pages/SavedSearches/useSavedSearches.ts'; +import type { SideBarView } from '../Sidebar'; + +type Dialog = { + party: string; +}; + +type PartyGroupType = 'End_user' | 'Other_people' | 'Organizations'; + +export type MergedPartyGroup = { + [key in PartyGroupType]: { + title: string; + parties: MergedParty[]; + isSearchResults?: boolean; + }; +}; + +type MergedParty = { + label: string; + isCompany: boolean; + value: string; + onSelectValues: string[]; + count: number; + isCurrentEndUser: boolean; +}; + +export const getOptionsGroups = ( + parties: PartyFieldsFragment[], + dialogsByCounterContext: Record, + savedSearches: SavedSearchesFieldsFragment[] | undefined, + counterContext: SideBarView = 'inbox', +): MergedPartyGroup => { + return groupParties( + parties.map((party) => + mergePartiesByName(party, dialogsByCounterContext[counterContext], savedSearches, counterContext), + ), + ); +}; + +export function groupParties(parties: MergedParty[]): MergedPartyGroup { + const groupingThreshold = 3; + + if (parties.length <= groupingThreshold) { + return { + Other_people: { title: '', parties }, + End_user: { title: '', parties: [] }, + Organizations: { title: '', parties: [] }, + }; + } + + const initialGroup: MergedPartyGroup = { + End_user: { title: '', parties: [] }, + Other_people: { title: 'parties.labels.persons', parties: [] }, + Organizations: { title: 'parties.labels.organizations', parties: [] }, + }; + + const grouped = parties.reduce((acc, party) => { + if (party.isCurrentEndUser) { + acc.End_user.parties.push(party); + } else if (party.isCompany) { + acc.Organizations.parties.push(party); + } else { + acc.Other_people.parties.push(party); + } + return acc; + }, initialGroup); + + /* add 'All organizations' option if there are more than one organization */ + const updatedOrganizations = + grouped.Organizations.parties.length > 1 + ? { + ...grouped.Organizations, + parties: [ + ...grouped.Organizations.parties, + { + label: t('parties.labels.all_organizations'), + isCompany: true, + value: 'ALL_ORGANIZATIONS', + onSelectValues: grouped.Organizations.parties.map((party) => party.value), + count: grouped.Organizations.parties.reduce((acc, party) => acc + party.count, 0), + isCurrentEndUser: false, + }, + ], + } + : grouped.Organizations; + + return { + ...grouped, + Organizations: updatedOrganizations, + }; +} + +export function mergePartiesByName( + party: PartyFieldsFragment, + dialogs: Dialog[], + savedSearches?: SavedSearchesFieldsFragment[], + counterContext?: SideBarView, +): MergedParty { + let count: number; + if (counterContext === 'saved-searches') { + count = filterSavedSearches(savedSearches ?? [], [party.party]).length ?? 0; + } else { + count = dialogs.filter((dialogs) => dialogs?.party === party.party).length ?? 0; + } + + const mergedParties = party.subParties?.reduce( + (acc, subParty) => { + if (subParty.name === party.name) { + acc.push(subParty.party); + } else { + acc.push(subParty.party); + } + return acc; + }, + [party.party], + ) ?? [party.party]; + + return { + label: party.name, + isCompany: party.partyType === 'Organization', + value: party.party, + onSelectValues: mergedParties, + count, + isCurrentEndUser: party.isCurrentEndUser ?? false, + }; +} diff --git a/packages/frontend/src/components/PartyDropdown/partyDropdown.module.css b/packages/frontend/src/components/PartyDropdown/partyDropdown.module.css index b249b9c0f..49859ea26 100644 --- a/packages/frontend/src/components/PartyDropdown/partyDropdown.module.css +++ b/packages/frontend/src/components/PartyDropdown/partyDropdown.module.css @@ -1,12 +1,8 @@ -.partyListContent { - z-index: 1002; - display: flex; - align-items: center; - justify-content: space-between; - font-weight: 400; -} - .partyListLabel { font-size: 1rem; margin-left: 0.5rem; } + +.partyListItem { + list-style: none; +} diff --git a/packages/frontend/src/components/Sidebar/Sidebar.tsx b/packages/frontend/src/components/Sidebar/Sidebar.tsx index f043157f0..482ce2ae5 100644 --- a/packages/frontend/src/components/Sidebar/Sidebar.tsx +++ b/packages/frontend/src/components/Sidebar/Sidebar.tsx @@ -119,7 +119,7 @@ export const Sidebar: React.FC = ({ itemsPerViewCount }) => { count={itemsPerViewCount.deleted} isActive={pathname === Routes.deleted} path={Routes.deleted} - classNames={styles.lastItem} + className={styles.lastItem} useProfiledHover disabled /> diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts index 4140be09c..d736d9635 100644 --- a/packages/frontend/src/components/index.ts +++ b/packages/frontend/src/components/index.ts @@ -14,3 +14,4 @@ export * from './HorizontalLine'; export * from './Avatar'; export * from './Badge'; export * from './MetaDataFields'; +export * from './PartyDropdown'; diff --git a/packages/frontend/src/i18n/resources/en.json b/packages/frontend/src/i18n/resources/en.json index dfef67628..4091d8635 100644 --- a/packages/frontend/src/i18n/resources/en.json +++ b/packages/frontend/src/i18n/resources/en.json @@ -75,7 +75,9 @@ "menuBar.chat.label": "Press here to go to chat", "notifications.count": "{count, plural, one {# unread message} other {# unread messages}}", "parties.labels.organizations": "Organizations", + "parties.labels.all_organizations": "All organizations", "parties.labels.persons": "Persons", + "partyDropdown.filter_results": "{count, plural, one {# result} other {# results}}", "partyDropdown.selectParty": "Choose party", "route.archived": "Archive", "route.deleted": "Trash", diff --git a/packages/frontend/src/i18n/resources/nb.json b/packages/frontend/src/i18n/resources/nb.json index 12067d98b..e15f6ca79 100644 --- a/packages/frontend/src/i18n/resources/nb.json +++ b/packages/frontend/src/i18n/resources/nb.json @@ -75,8 +75,10 @@ "menuBar.chat.label": "Trykk her for å gå til chat", "notifications.count": "{count, plural, one {# ulest melding} other {# uleste meldinger}}", "parties.labels.organizations": "Organisasjoner", + "parties.labels.all_organizations": "Alle virksomheter", "parties.labels.persons": "Personer", "partyDropdown.selectParty": "Velg virksomhet", + "partyDropdown.filter_results": "{count, plural, one {# treff} other {# treff}}", "route.archived": "Arkiv", "route.deleted": "Papirkurv", "route.drafts": "Utkast", diff --git a/packages/frontend/src/pages/Inbox/Inbox.tsx b/packages/frontend/src/pages/Inbox/Inbox.tsx index 120e71967..cf43b0665 100644 --- a/packages/frontend/src/pages/Inbox/Inbox.tsx +++ b/packages/frontend/src/pages/Inbox/Inbox.tsx @@ -13,14 +13,14 @@ import type { InboxItemMetaField } from '../../components'; import { type Filter, FilterBar } from '../../components'; import { useSelectedDialogs } from '../../components'; import { useSnackbar } from '../../components'; +import { PartyDropdown } from '../../components'; import type { FilterBarRef } from '../../components/FilterBar/FilterBar.tsx'; import { FosToolbar } from '../../components/FosToolbar'; import { InboxItemsHeader } from '../../components/InboxItem/InboxItemsHeader.tsx'; -import { PartyDropdown } from '../../components/PartyDropdown'; import { SaveSearchButton } from '../../components/SavedSearchButton/SaveSearchButton.tsx'; import type { SortOrderDropdownRef, SortingOrder } from '../../components/SortOrderDropdown/SortOrderDropdown.tsx'; -import { FeatureFlagKeys } from '../../featureFlags/FeatureFlags.ts'; -import { useFeatureFlag } from '../../featureFlags/useFeatureFlag.ts'; +import { FeatureFlagKeys } from '../../featureFlags'; +import { useFeatureFlag } from '../../featureFlags'; import { useFormat } from '../../i18n/useDateFnsLocale.tsx'; import { InboxSkeleton } from './InboxSkeleton.tsx'; import { filterDialogs, getFilterBarSettings } from './filters.ts'; diff --git a/packages/frontend/src/pages/SavedSearches/SavedSearchesPage.tsx b/packages/frontend/src/pages/SavedSearches/SavedSearchesPage.tsx index 820bdbb12..b0ddc9bb8 100644 --- a/packages/frontend/src/pages/SavedSearches/SavedSearchesPage.tsx +++ b/packages/frontend/src/pages/SavedSearches/SavedSearchesPage.tsx @@ -20,9 +20,9 @@ export const SavedSearchesPage = () => { const [selectedSavedSearch, setSelectedSavedSearch] = useState(null); const [selectedDeleteItem, setSelectedDeleteItem] = useState(undefined); const { t } = useTranslation(); - const { selectedParties } = useParties(); + const { selectedPartyIds } = useParties(); const { currentPartySavedSearches: savedSearches, isLoading: isLoadingSavedSearches } = - useSavedSearches(selectedParties); + useSavedSearches(selectedPartyIds); const deleteDialogRef = useRef(null); const editSavedSearchDialogRef = useRef(null); const formatDistance = useFormatDistance(); diff --git a/packages/frontend/src/pages/SavedSearches/useSavedSearches.ts b/packages/frontend/src/pages/SavedSearches/useSavedSearches.ts index 9c9b46048..d0cebcb5e 100644 --- a/packages/frontend/src/pages/SavedSearches/useSavedSearches.ts +++ b/packages/frontend/src/pages/SavedSearches/useSavedSearches.ts @@ -1,4 +1,4 @@ -import type { PartyFieldsFragment, SavedSearchesFieldsFragment, SavedSearchesQuery } from 'bff-types-generated'; +import type { SavedSearchesFieldsFragment, SavedSearchesQuery } from 'bff-types-generated'; import { useQuery } from 'react-query'; import { fetchSavedSearches } from '../../api/queries.ts'; @@ -11,23 +11,31 @@ interface UseSavedSearchesOutput { export const filterSavedSearches = ( savedSearches: SavedSearchesFieldsFragment[], - selectedParties: PartyFieldsFragment[], + selectedPartyIds: string[], ): SavedSearchesFieldsFragment[] => { return (savedSearches ?? []).filter((savedSearch) => { if (!savedSearch?.data.urn?.length) { return true; } - if (selectedParties?.length !== savedSearch?.data?.urn?.length) { + + if (savedSearch?.data?.urn?.length > 0) { + return selectedPartyIds.includes(savedSearch?.data.urn[0]!); + } + + if (selectedPartyIds?.length !== savedSearch?.data?.urn?.length) { return false; } - return selectedParties?.every((party) => savedSearch?.data?.urn?.includes(party.party)); + return selectedPartyIds?.every((party) => savedSearch?.data?.urn?.includes(party)); }); }; -export const useSavedSearches = (selectedParties?: PartyFieldsFragment[]): UseSavedSearchesOutput => { - const { data, isLoading, isSuccess } = useQuery('savedSearches', fetchSavedSearches); +export const useSavedSearches = (selectedPartyIds?: string[]): UseSavedSearchesOutput => { + const { data, isLoading, isSuccess } = useQuery( + ['savedSearches', selectedPartyIds], + fetchSavedSearches, + ); const savedSearchesUnfiltered = data?.savedSearches as SavedSearchesFieldsFragment[]; - const currentPartySavedSearches = filterSavedSearches(savedSearchesUnfiltered, selectedParties || []); + const currentPartySavedSearches = filterSavedSearches(savedSearchesUnfiltered, selectedPartyIds || []); return { savedSearches: savedSearchesUnfiltered, isLoading, isSuccess, currentPartySavedSearches }; }; diff --git a/packages/storybook/src/stories/PartyList/partyList.stories.tsx b/packages/storybook/src/stories/PartyList/partyList.stories.tsx new file mode 100644 index 000000000..39b2eed75 --- /dev/null +++ b/packages/storybook/src/stories/PartyList/partyList.stories.tsx @@ -0,0 +1,134 @@ +import type { Meta } from '@storybook/react'; +import { PartyList } from 'frontend'; +import { type MergedPartyGroup, getOptionsGroups } from 'frontend/src/components/PartyDropdown/mergePartiesByName.ts'; +import { useMemo, useState } from 'react'; + +export default { + title: 'Components/PartyList', + component: PartyList, + tags: ['autodocs'], + decorators: [ + (Story) => ( +
    + +
    + ), + ], + parameters: { + layout: 'fullscreen', + }, +} as Meta; + +const parties = [ + { + party: 'urn:altinn:person:identifier-no:1', + partyType: 'Person', + name: 'Me Messon', + isCurrentEndUser: true, + }, + { + party: 'urn:altinn:organization:identifier-no:1', + partyType: 'Organization', + name: 'Digitaliseringsdirektoratet', + isCurrentEndUser: false, + }, + { + party: 'urn:altinn:organization:identifier-no:2', + partyType: 'Organization', + name: 'Testbedrift AS', + isCurrentEndUser: false, + }, + { + party: 'urn:altinn:organization:identifier-no:3', + partyType: 'Organization', + name: 'Testdirektoratet AS', + isCurrentEndUser: false, + }, + { + party: 'urn:altinn:organization:identifier-no:4', + partyType: 'Organization', + name: 'TestTestTest AS', + isCurrentEndUser: false, + }, +]; + +export const SimpleExample = () => { + const [selectedPartyIds, setSelectedPartyIds] = useState([]); + const dialogsByView = { + inbox: [], + 'saved-searches': [], + }; + + const optionsGroups: MergedPartyGroup = useMemo( + () => getOptionsGroups(parties, dialogsByView, [], 'inbox'), + [parties], + ); + + return ( +
    + +
    + ); +}; + +export const MultiplePeople = () => { + const [selectedPartyIds, setSelectedPartyIds] = useState([]); + const dialogsByView = { + inbox: [], + 'saved-searches': [], + }; + + const customParties = [ + ...parties, + { + party: 'urn:altinn:person:identifier-no:2', + partyType: 'Person', + name: 'My Loving Daughter', + isCurrentEndUser: false, + }, + { + party: 'urn:altinn:person:identifier-no:3', + partyType: 'Person', + name: 'My Loving Son', + isCurrentEndUser: false, + }, + ]; + + const optionsGroups: MergedPartyGroup = useMemo( + () => getOptionsGroups(customParties, dialogsByView, [], 'inbox'), + [customParties], + ); + + return ( +
    + +
    + ); +}; + +export const ExampleWithFilter = () => { + const [selectedPartyIds, setSelectedPartyIds] = useState([]); + const dialogsByView = { + inbox: [], + 'saved-searches': [], + }; + const optionsGroups: MergedPartyGroup = useMemo( + () => getOptionsGroups(parties, dialogsByView, [], 'inbox'), + [parties], + ); + + return ( +
    + +
    + ); +};