diff --git a/app/javascript/flavours/glitch/api/collections.ts b/app/javascript/flavours/glitch/api/collections.ts index 49ea60c089..50d0ffa516 100644 --- a/app/javascript/flavours/glitch/api/collections.ts +++ b/app/javascript/flavours/glitch/api/collections.ts @@ -11,6 +11,7 @@ import type { ApiCreateCollectionPayload, ApiUpdateCollectionPayload, ApiCollectionsJSON, + WrappedCollectionAccountItem, } from '../api_types/collections'; export const apiCreateCollection = (collection: ApiCreateCollectionPayload) => @@ -37,3 +38,14 @@ export const apiGetAccountCollections = (accountId: string) => apiRequestGet( `v1_alpha/accounts/${accountId}/collections`, ); + +export const apiAddCollectionItem = (collectionId: string, accountId: string) => + apiRequestPost( + `v1_alpha/collections/${collectionId}/items`, + { account_id: accountId }, + ); + +export const apiRemoveCollectionItem = (collectionId: string, itemId: string) => + apiRequestDelete( + `v1_alpha/collections/${collectionId}/items/${itemId}`, + ); diff --git a/app/javascript/flavours/glitch/api_types/collections.ts b/app/javascript/flavours/glitch/api_types/collections.ts index cded45f1a3..ec6eaabaa0 100644 --- a/app/javascript/flavours/glitch/api_types/collections.ts +++ b/app/javascript/flavours/glitch/api_types/collections.ts @@ -53,6 +53,7 @@ export interface ApiCollectionWithAccountsJSON extends ApiWrappedCollectionJSON * Nested account item */ interface CollectionAccountItem { + id: string; account_id?: string; // Only present when state is 'accepted' (or the collection is your own) state: 'pending' | 'accepted' | 'rejected' | 'revoked'; position: number; diff --git a/app/javascript/flavours/glitch/components/account/index.tsx b/app/javascript/flavours/glitch/components/account/index.tsx index bcb84f2f4e..358119e2e9 100644 --- a/app/javascript/flavours/glitch/components/account/index.tsx +++ b/app/javascript/flavours/glitch/components/account/index.tsx @@ -74,6 +74,7 @@ interface AccountProps { defaultAction?: 'block' | 'mute'; withBio?: boolean; withMenu?: boolean; + children?: React.ReactNode; } export const Account: React.FC = ({ @@ -84,6 +85,7 @@ export const Account: React.FC = ({ defaultAction, withBio, withMenu = true, + children, }) => { const intl = useIntl(); const { signedIn } = useIdentity(); @@ -355,6 +357,8 @@ export const Account: React.FC = ({ {button} )} + + {children} ); diff --git a/app/javascript/flavours/glitch/components/form_fields/combobox.module.scss b/app/javascript/flavours/glitch/components/form_fields/combobox.module.scss index 4b5bfef1af..68c091a6d2 100644 --- a/app/javascript/flavours/glitch/components/form_fields/combobox.module.scss +++ b/app/javascript/flavours/glitch/components/form_fields/combobox.module.scss @@ -25,14 +25,17 @@ .popover { z-index: 9999; box-sizing: border-box; + max-height: max(200px, 30dvh); padding: 4px; border-radius: 4px; color: var(--color-text-primary); background: var(--color-bg-primary); border: 1px solid var(--color-border-primary); box-shadow: var(--dropdown-shadow); - - // backdrop-filter: $backdrop-blur-filter; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-gutter: stable; + overscroll-behavior-y: contain; } .menuItem { @@ -47,7 +50,7 @@ cursor: pointer; user-select: none; - &[aria-selected='true'] { + &[data-highlighted='true'] { color: var(--color-text-on-brand-base); background: var(--color-bg-brand-base); diff --git a/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx b/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx index 9751748ea7..cfcfc1f5d7 100644 --- a/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx @@ -18,24 +18,30 @@ import { FormFieldWrapper } from './form_field_wrapper'; import type { CommonFieldWrapperProps } from './form_field_wrapper'; import { TextInput } from './text_input_field'; -interface Item { +interface ComboboxItem { id: string; } +export interface ComboboxItemState { + isSelected: boolean; + isDisabled: boolean; +} + interface ComboboxProps< - T extends Item, + T extends ComboboxItem, > extends ComponentPropsWithoutRef<'input'> { value: string; onChange: React.ChangeEventHandler; isLoading?: boolean; items: T[]; getItemId: (item: T) => string; + getIsItemSelected?: (item: T) => boolean; getIsItemDisabled?: (item: T) => boolean; - renderItem: (item: T) => React.ReactElement; + renderItem: (item: T, state: ComboboxItemState) => React.ReactElement; onSelectItem: (item: T) => void; } -interface Props +interface Props extends ComboboxProps, CommonFieldWrapperProps {} /** @@ -43,7 +49,7 @@ interface Props * from a large list of options by searching or filtering. */ -export const ComboboxFieldWithRef = ( +export const ComboboxFieldWithRef = ( { id, label, hint, hasError, required, ...otherProps }: Props, ref: React.ForwardedRef, ) => ( @@ -61,7 +67,7 @@ export const ComboboxFieldWithRef = ( // Using a type assertion to maintain the full type signature of ComboboxWithRef // (including its generic type) after wrapping it with `forwardRef`. export const ComboboxField = forwardRef(ComboboxFieldWithRef) as { - ( + ( props: Props & { ref?: React.ForwardedRef }, ): ReturnType; displayName: string; @@ -69,13 +75,15 @@ export const ComboboxField = forwardRef(ComboboxFieldWithRef) as { ComboboxField.displayName = 'ComboboxField'; -const ComboboxWithRef = ( +const ComboboxWithRef = ( { value, isLoading = false, items, getItemId, getIsItemDisabled, + getIsItemSelected, + disabled, renderItem, onSelectItem, onChange, @@ -88,6 +96,7 @@ const ComboboxWithRef = ( const intl = useIntl(); const wrapperRef = useRef(null); const inputRef = useRef(); + const popoverRef = useRef(null); const [highlightedItemId, setHighlightedItemId] = useState( null, @@ -101,11 +110,13 @@ const ComboboxWithRef = ( }); const showStatusMessageInMenu = !!statusMessage && value.length > 0 && items.length === 0; - const hasMenuContent = items.length > 0 || showStatusMessageInMenu; + const hasMenuContent = + !disabled && (items.length > 0 || showStatusMessageInMenu); const isMenuOpen = shouldMenuOpen && hasMenuContent; const openMenu = useCallback(() => { setShouldMenuOpen(true); + inputRef.current?.focus(); }, []); const closeMenu = useCallback(() => { @@ -118,6 +129,18 @@ const ComboboxWithRef = ( setHighlightedItemId(firstItemId); }, [getItemId, items]); + const highlightItem = useCallback((id: string | null) => { + setHighlightedItemId(id); + if (id) { + const itemElement = popoverRef.current?.querySelector( + `[data-item-id='${id}']`, + ); + if (itemElement && popoverRef.current) { + scrollItemIntoView(itemElement, popoverRef.current); + } + } + }, []); + const handleInputChange = useCallback( (e: React.ChangeEvent) => { onChange(e); @@ -127,14 +150,14 @@ const ComboboxWithRef = ( [onChange, resetHighlight], ); - const handleHighlightItem = useCallback( + const handleItemMouseEnter = useCallback( (e: React.MouseEvent) => { const { itemId } = e.currentTarget.dataset; if (itemId) { - setHighlightedItemId(itemId); + highlightItem(itemId); } }, - [], + [highlightItem], ); const selectItem = useCallback( @@ -175,10 +198,10 @@ const ComboboxWithRef = ( // If no item is highlighted yet, highlight the first or last if (direction > 0) { const firstItem = items.at(0); - setHighlightedItemId(firstItem ? getItemId(firstItem) : null); + highlightItem(firstItem ? getItemId(firstItem) : null); } else { const lastItem = items.at(-1); - setHighlightedItemId(lastItem ? getItemId(lastItem) : null); + highlightItem(lastItem ? getItemId(lastItem) : null); } } else { // If there is a highlighted item, select the next or previous item @@ -191,12 +214,12 @@ const ComboboxWithRef = ( } const newHighlightedItem = items[newIndex]; - setHighlightedItemId( + highlightItem( newHighlightedItem ? getItemId(newHighlightedItem) : null, ); } }, - [getItemId, highlightedItemId, items], + [getItemId, highlightItem, highlightedItemId, items], ); useOnClickOutside(wrapperRef, closeMenu); @@ -231,7 +254,6 @@ const ComboboxWithRef = ( if (isMenuOpen) { e.preventDefault(); selectHighlightedItem(); - closeMenu(); } } if (e.key === 'Escape') { @@ -271,9 +293,10 @@ const ComboboxWithRef = ( ( {isMenuOpen && statusMessage} } container={wrapperRef} popperConfig={{ @@ -331,19 +354,30 @@ const ComboboxWithRef = ( {items.map((item) => { const id = getItemId(item); const isDisabled = getIsItemDisabled?.(item); + const isHighlighted = id === highlightedItemId; + // If `getIsItemSelected` is defined, we assume 'multi-select' + // behaviour and don't set `aria-selected` based on highlight, + // but based on selected item state. + const isSelected = getIsItemSelected + ? getIsItemSelected(item) + : isHighlighted; return ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events
  • - {renderItem(item)} + {renderItem(item, { + isSelected, + isDisabled: isDisabled ?? false, + })}
  • ); })} @@ -359,7 +393,7 @@ const ComboboxWithRef = ( // Using a type assertion to maintain the full type signature of ComboboxWithRef // (including its generic type) after wrapping it with `forwardRef`. export const Combobox = forwardRef(ComboboxWithRef) as { - ( + ( props: ComboboxProps & { ref?: React.ForwardedRef }, ): ReturnType; displayName: string; @@ -406,3 +440,23 @@ function useGetA11yStatusMessage({ } return ''; } + +const SCROLL_MARGIN = 6; + +function scrollItemIntoView(item: HTMLElement, scrollParent: HTMLElement) { + const itemTopEdge = item.offsetTop; + const itemBottomEdge = itemTopEdge + item.offsetHeight; + + // If item is above scroll area, scroll up + if (itemTopEdge < scrollParent.scrollTop) { + scrollParent.scrollTop = itemTopEdge - SCROLL_MARGIN; + } + // If item is below scroll area, scroll down + else if ( + itemBottomEdge > + scrollParent.scrollTop + scrollParent.offsetHeight + ) { + scrollParent.scrollTop = + itemBottomEdge - scrollParent.offsetHeight + SCROLL_MARGIN; + } +} diff --git a/app/javascript/flavours/glitch/components/form_fields/index.ts b/app/javascript/flavours/glitch/components/form_fields/index.ts index ef4a6567e5..fca366106f 100644 --- a/app/javascript/flavours/glitch/components/form_fields/index.ts +++ b/app/javascript/flavours/glitch/components/form_fields/index.ts @@ -3,7 +3,11 @@ export { Fieldset } from './fieldset'; export { TextInputField, TextInput } from './text_input_field'; export { TextAreaField, TextArea } from './text_area_field'; export { CheckboxField, Checkbox } from './checkbox_field'; -export { ComboboxField, Combobox } from './combobox_field'; +export { + ComboboxField, + Combobox, + type ComboboxItemState, +} from './combobox_field'; export { RadioButtonField, RadioButton } from './radio_button_field'; export { ToggleField, Toggle } from './toggle_field'; export { SelectField, Select } from './select_field'; diff --git a/app/javascript/flavours/glitch/features/collections/editor/accounts.tsx b/app/javascript/flavours/glitch/features/collections/editor/accounts.tsx index 9b739bcb13..b01ecac39d 100644 --- a/app/javascript/flavours/glitch/features/collections/editor/accounts.tsx +++ b/app/javascript/flavours/glitch/features/collections/editor/accounts.tsx @@ -1,67 +1,366 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo, useState } from 'react'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; import { useHistory, useLocation } from 'react-router-dom'; +import CancelIcon from '@/material-icons/400-24px/cancel.svg?react'; +import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections'; +import { Account } from 'flavours/glitch/components/account'; +import { Avatar } from 'flavours/glitch/components/avatar'; import { Button } from 'flavours/glitch/components/button'; -import { FormStack } from 'flavours/glitch/components/form_fields'; +import { Callout } from 'flavours/glitch/components/callout'; +import { DisplayName } from 'flavours/glitch/components/display_name'; +import { EmptyState } from 'flavours/glitch/components/empty_state'; +import { + FormStack, + ComboboxField, +} from 'flavours/glitch/components/form_fields'; +import { Icon } from 'flavours/glitch/components/icon'; +import { IconButton } from 'flavours/glitch/components/icon_button'; +import ScrollableList from 'flavours/glitch/components/scrollable_list'; +import { useSearchAccounts } from 'flavours/glitch/features/lists/use_search_accounts'; +import { + addCollectionItem, + removeCollectionItem, +} from 'flavours/glitch/reducers/slices/collections'; +import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; import type { TempCollectionState } from './state'; -import { getInitialState } from './state'; +import { getCollectionEditorState } from './state'; +import classes from './styles.module.scss'; import { WizardStepHeader } from './wizard_step_header'; +const MIN_ACCOUNT_COUNT = 1; +const MAX_ACCOUNT_COUNT = 25; + +const AddedAccountItem: React.FC<{ + accountId: string; + isRemovable: boolean; + onRemove: (id: string) => void; +}> = ({ accountId, isRemovable, onRemove }) => { + const intl = useIntl(); + + const handleRemoveAccount = useCallback(() => { + onRemove(accountId); + }, [accountId, onRemove]); + + return ( + + {isRemovable && ( + + )} + + ); +}; + +interface SuggestionItem { + id: string; + isSelected: boolean; +} + +const SuggestedAccountItem: React.FC = ({ id, isSelected }) => { + const account = useAppSelector((state) => state.accounts.get(id)); + + if (!account) return null; + + return ( + <> + + + {isSelected && ( + + )} + + ); +}; + +const renderAccountItem = (item: SuggestionItem) => ( + +); + +const getItemId = (item: SuggestionItem) => item.id; +const getIsItemSelected = (item: SuggestionItem) => item.isSelected; + export const CollectionAccounts: React.FC<{ collection?: ApiCollectionJSON | null; }> = ({ collection }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); const history = useHistory(); const location = useLocation(); + const { id, initialItemIds } = getCollectionEditorState( + collection, + location.state, + ); + const isEditMode = !!id; + const collectionItems = collection?.items; - const { id } = getInitialState(collection, location.state); + const [searchValue, setSearchValue] = useState(''); + // This state is only used when creating a new collection. + // In edit mode, the collection will be updated instantly + const [addedAccountIds, setAccountIds] = useState(initialItemIds); + const accountIds = useMemo( + () => + isEditMode + ? (collectionItems + ?.map((item) => item.account_id) + .filter((id): id is string => !!id) ?? []) + : addedAccountIds, + [isEditMode, collectionItems, addedAccountIds], + ); + + const hasMaxAccounts = accountIds.length === MAX_ACCOUNT_COUNT; + const hasMinAccounts = accountIds.length === MIN_ACCOUNT_COUNT; + const hasTooFewAccounts = accountIds.length < MIN_ACCOUNT_COUNT; + const canSubmit = !hasTooFewAccounts; + + const { + accountIds: suggestedAccountIds, + isLoading: isLoadingSuggestions, + searchAccounts, + } = useSearchAccounts(); + + const suggestedItems = suggestedAccountIds.map((id) => ({ + id, + isSelected: accountIds.includes(id), + })); + + const handleSearchValueChange = useCallback( + (e: React.ChangeEvent) => { + setSearchValue(e.target.value); + searchAccounts(e.target.value); + }, + [searchAccounts], + ); + + const handleSearchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + } + }, + [], + ); + + const toggleAccountItem = useCallback((item: SuggestionItem) => { + setAccountIds((ids) => + ids.includes(item.id) + ? ids.filter((id) => id !== item.id) + : [...ids, item.id], + ); + }, []); + + const instantRemoveAccountItem = useCallback( + (accountId: string) => { + const itemId = collectionItems?.find( + (item) => item.account_id === accountId, + )?.id; + if (itemId && id) { + if ( + window.confirm( + intl.formatMessage({ + id: 'collections.confirm_account_removal', + defaultMessage: + 'Are you sure you want to remove this account from this collection?', + }), + ) + ) { + void dispatch(removeCollectionItem({ collectionId: id, itemId })); + } + } + }, + [collectionItems, dispatch, id, intl], + ); + + const instantToggleAccountItem = useCallback( + (item: SuggestionItem) => { + if (accountIds.includes(item.id)) { + instantRemoveAccountItem(item.id); + } else { + if (id) { + void dispatch( + addCollectionItem({ collectionId: id, accountId: item.id }), + ); + } + } + }, + [accountIds, dispatch, id, instantRemoveAccountItem], + ); + + const handleRemoveAccountItem = useCallback( + (accountId: string) => { + if (isEditMode) { + instantRemoveAccountItem(accountId); + } else { + setAccountIds((ids) => ids.filter((id) => id !== accountId)); + } + }, + [isEditMode, instantRemoveAccountItem], + ); const handleSubmit = useCallback( (e: React.FormEvent) => { e.preventDefault(); + if (!canSubmit) { + return; + } + if (!id) { - history.push(`/collections/new/details`); + history.push(`/collections/new/details`, { + account_ids: accountIds, + }); } }, - [id, history], + [canSubmit, id, history, accountIds], ); return ( - - {!id && ( - + + {!id && ( + + } + description={ + + } + /> + )} + } - description={ - + hint={ + hasMaxAccounts ? ( + + ) : undefined + } + value={hasMaxAccounts ? '' : searchValue} + onChange={handleSearchValueChange} + onKeyDown={handleSearchKeyDown} + disabled={hasMaxAccounts} + isLoading={isLoadingSuggestions} + items={suggestedItems} + getItemId={getItemId} + getIsItemSelected={getIsItemSelected} + renderItem={renderAccountItem} + onSelectItem={ + isEditMode ? instantToggleAccountItem : toggleAccountItem } /> - )} -
    - + )} +
    )} - - -
    + + )} + ); }; diff --git a/app/javascript/flavours/glitch/features/collections/editor/details.tsx b/app/javascript/flavours/glitch/features/collections/editor/details.tsx index 0b03e5b03c..dccb82ea97 100644 --- a/app/javascript/flavours/glitch/features/collections/editor/details.tsx +++ b/app/javascript/flavours/glitch/features/collections/editor/details.tsx @@ -19,7 +19,8 @@ import { updateCollection } from 'flavours/glitch/reducers/slices/collections'; import { useAppDispatch } from 'flavours/glitch/store'; import type { TempCollectionState } from './state'; -import { getInitialState } from './state'; +import { getCollectionEditorState } from './state'; +import classes from './styles.module.scss'; import { WizardStepHeader } from './wizard_step_header'; export const CollectionDetails: React.FC<{ @@ -29,10 +30,8 @@ export const CollectionDetails: React.FC<{ const history = useHistory(); const location = useLocation(); - const { id, initialName, initialDescription, initialTopic } = getInitialState( - collection, - location.state, - ); + const { id, initialName, initialDescription, initialTopic, initialItemIds } = + getCollectionEditorState(collection, location.state); const [name, setName] = useState(initialName); const [description, setDescription] = useState(initialDescription); @@ -79,13 +78,14 @@ export const CollectionDetails: React.FC<{ name, description, tag_name: topic || null, + account_ids: initialItemIds, }; history.replace('/collections/new', payload); history.push('/collections/new/settings', payload); } }, - [id, dispatch, name, description, topic, history], + [id, name, description, topic, dispatch, history, initialItemIds], ); return ( @@ -158,7 +158,7 @@ export const CollectionDetails: React.FC<{ maxLength={40} /> -
    +