[Glitch] Mark pending accounts in collection editor

Port 41a3679d83f5f767c013dd4a29c153869469b03a to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
diondiondion 2026-04-29 12:35:09 +02:00 committed by Claire
parent 49f60c0b76
commit 26cee35f50
4 changed files with 84 additions and 44 deletions

View File

@ -116,7 +116,7 @@ const RevokeControls: React.FC<{
); );
}; };
const PendingNote: React.FC = () => { export const PendingNote: React.FC = () => {
return ( return (
<Callout <Callout
variant='subtle' variant='subtle'

View File

@ -6,11 +6,11 @@ import { useHistory } from 'react-router-dom';
import type { Map as ImmutableMap } from 'immutable'; import type { Map as ImmutableMap } from 'immutable';
import { useComboboxItemProps } from '@/flavours/glitch/components/form_fields/combobox_field';
import type { ApiMutedAccountJSON } from 'flavours/glitch/api_types/accounts'; import type { ApiMutedAccountJSON } from 'flavours/glitch/api_types/accounts';
import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections'; import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
import { AccountListItem } from 'flavours/glitch/components/account_list_item'; import { AccountListItem } from 'flavours/glitch/components/account_list_item';
import { Avatar } from 'flavours/glitch/components/avatar'; import { Avatar } from 'flavours/glitch/components/avatar';
import { PendingBadge } from 'flavours/glitch/components/badge';
import { Button } from 'flavours/glitch/components/button'; import { Button } from 'flavours/glitch/components/button';
import { DisplayName } from 'flavours/glitch/components/display_name'; import { DisplayName } from 'flavours/glitch/components/display_name';
import { useAccountHandle } from 'flavours/glitch/components/display_name/default'; import { useAccountHandle } from 'flavours/glitch/components/display_name/default';
@ -19,6 +19,7 @@ import {
FormStack, FormStack,
ComboboxField, ComboboxField,
} from 'flavours/glitch/components/form_fields'; } from 'flavours/glitch/components/form_fields';
import { useComboboxItemProps } from 'flavours/glitch/components/form_fields/combobox_field';
import { import {
ListItemContent, ListItemContent,
ListItemWrapper, ListItemWrapper,
@ -34,12 +35,14 @@ import { domain } from 'flavours/glitch/initial_state';
import type { Relationship } from 'flavours/glitch/models/relationship'; import type { Relationship } from 'flavours/glitch/models/relationship';
import { import {
addCollectionItem, addCollectionItem,
getCollectionItemIds, getEditorCollectionItems,
removeCollectionItem, removeCollectionItem,
updateCollectionEditorField, updateCollectionEditorField,
} from 'flavours/glitch/reducers/slices/collections'; } from 'flavours/glitch/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
import { PendingNote } from '../detail';
import classes from './styles.module.scss'; import classes from './styles.module.scss';
import { WizardStepTitle } from './wizard_step_title'; import { WizardStepTitle } from './wizard_step_title';
@ -47,8 +50,9 @@ const MAX_ACCOUNT_COUNT = 25;
const AddedAccountItem: React.FC<{ const AddedAccountItem: React.FC<{
accountId: string; accountId: string;
pending?: boolean;
onRemove: (id: string) => void; onRemove: (id: string) => void;
}> = ({ accountId, onRemove }) => { }> = ({ accountId, pending, onRemove }) => {
const handleRemoveAccount = useCallback(() => { const handleRemoveAccount = useCallback(() => {
onRemove(accountId); onRemove(accountId);
}, [accountId, onRemove]); }, [accountId, onRemove]);
@ -65,7 +69,13 @@ const AddedAccountItem: React.FC<{
[handleRemoveAccount], [handleRemoveAccount],
); );
return <AccountListItem accountId={accountId} renderButton={renderButton} />; return (
<AccountListItem
accountId={accountId}
badge={pending && <PendingBadge />}
renderButton={renderButton}
/>
);
}; };
const SuggestedAccountItem: React.FC<{ id: string }> = ({ id }) => { const SuggestedAccountItem: React.FC<{ id: string }> = ({ id }) => {
@ -186,22 +196,26 @@ export const CollectionAccounts: React.FC<{
const { id, items: collectionItems } = collection ?? {}; const { id, items: collectionItems } = collection ?? {};
const isEditMode = !!id; const isEditMode = !!id;
const addedAccountIds = useAppSelector( const editorItemsFromState = useAppSelector(
(state) => state.collections.editor.accountIds, (state) => state.collections.editor.items,
); );
// In edit mode, we're bypassing state and just return collection items directly, // In edit mode, we're bypassing our Redux state and just work on the
// since they're edited "live", saving after each addition/deletion // collection items directly since they're edited "live", saving right
const accountIds = useMemo( // after each addition/deletion
const editorItems = useMemo(
() => () =>
isEditMode ? getCollectionItemIds(collectionItems) : addedAccountIds, isEditMode
[isEditMode, collectionItems, addedAccountIds], ? getEditorCollectionItems(collectionItems)
: editorItemsFromState,
[isEditMode, collectionItems, editorItemsFromState],
); );
const hasPendingItems = editorItems.some((item) => item.state === 'pending');
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState('');
const hasAccounts = accountIds.length > 0; const hasItems = editorItems.length > 0;
const hasMaxAccounts = accountIds.length === MAX_ACCOUNT_COUNT; const hasMaxItems = editorItems.length === MAX_ACCOUNT_COUNT;
const { const {
accounts: suggestedAccounts, accounts: suggestedAccounts,
@ -211,7 +225,8 @@ export const CollectionAccounts: React.FC<{
} = useSearchAccounts({ } = useSearchAccounts({
withRelationships: true, withRelationships: true,
// Don't suggest accounts that were already added // Don't suggest accounts that were already added
filterResults: (account) => !accountIds.includes(account.id), filterResults: (account) =>
!editorItems.find((item) => item.account_id === account.id),
}); });
const relationships = useAppSelector((state) => state.relationships); const relationships = useAppSelector((state) => state.relationships);
@ -239,24 +254,33 @@ export const CollectionAccounts: React.FC<{
(accountId: string) => { (accountId: string) => {
dispatch( dispatch(
updateCollectionEditorField({ updateCollectionEditorField({
field: 'accountIds', field: 'items',
value: accountIds.filter((id) => id !== accountId), value: editorItems.filter((item) => item.account_id !== accountId),
}), }),
); );
}, },
[accountIds, dispatch], [editorItems, dispatch],
); );
const addAccountItem = useCallback( const addAccountItem = useCallback(
(item: ApiMutedAccountJSON) => { (item: ApiMutedAccountJSON) => {
dispatch( dispatch(
updateCollectionEditorField({ updateCollectionEditorField({
field: 'accountIds', field: 'items',
value: [...accountIds, item.id], value: [
...editorItems,
{
account_id: item.id,
state:
item.feature_approval.current_user === 'manual'
? 'pending'
: 'accepted',
},
],
}), }),
); );
}, },
[accountIds, dispatch], [editorItems, dispatch],
); );
const instantRemoveAccountItem = useCallback( const instantRemoveAccountItem = useCallback(
@ -322,12 +346,10 @@ export const CollectionAccounts: React.FC<{
e.preventDefault(); e.preventDefault();
if (!id) { if (!id) {
history.push(`/collections/new/details`, { history.push('/collections/new/details');
account_ids: accountIds,
});
} }
}, },
[id, history, accountIds], [id, history],
); );
const inputId = useId(); const inputId = useId();
@ -348,16 +370,17 @@ export const CollectionAccounts: React.FC<{
} }
/> />
)} )}
{hasPendingItems && <PendingNote />}
<ComboboxField <ComboboxField
id={inputId} id={inputId}
label={intl.formatMessage({ label={intl.formatMessage({
id: 'collections.search_accounts_label', id: 'collections.search_accounts_label',
defaultMessage: 'Search for an account to add', defaultMessage: 'Search for an account to add',
})} })}
value={hasMaxAccounts ? '' : searchValue} value={hasMaxItems ? '' : searchValue}
onChange={handleSearchValueChange} onChange={handleSearchValueChange}
onKeyDown={handleSearchKeyDown} onKeyDown={handleSearchKeyDown}
disabled={hasMaxAccounts} disabled={hasMaxItems}
isLoading={isLoadingSuggestions} isLoading={isLoadingSuggestions}
items={groupedItems} items={groupedItems}
getItemId={getItemId} getItemId={getItemId}
@ -366,7 +389,7 @@ export const CollectionAccounts: React.FC<{
renderGroupTitle={renderGroupTitle} renderGroupTitle={renderGroupTitle}
onSelectItem={handleSelectItem} onSelectItem={handleSelectItem}
status={ status={
hasMaxAccounts hasMaxItems
? { ? {
variant: 'warning', variant: 'warning',
message: intl.formatMessage({ message: intl.formatMessage({
@ -381,12 +404,12 @@ export const CollectionAccounts: React.FC<{
</header> </header>
<div> <div>
{hasAccounts && ( {hasItems && (
<AccountsHeadingElement className={classes.listHeading}> <AccountsHeadingElement className={classes.listHeading}>
<FormattedMessage <FormattedMessage
id='collections.hints.accounts_counter' id='collections.hints.accounts_counter'
defaultMessage='{count}/{max} accounts' defaultMessage='{count}/{max} accounts'
values={{ count: accountIds.length, max: MAX_ACCOUNT_COUNT }} values={{ count: editorItems.length, max: MAX_ACCOUNT_COUNT }}
/> />
</AccountsHeadingElement> </AccountsHeadingElement>
)} )}
@ -413,14 +436,15 @@ export const CollectionAccounts: React.FC<{
/> />
} }
> >
{accountIds.map((accountId, index) => ( {editorItems.map(({ account_id, state }, index) => (
<Article <Article
key={accountId} key={account_id}
aria-posinset={index} aria-posinset={index}
aria-setsize={accountIds.length} aria-setsize={editorItems.length}
> >
<AddedAccountItem <AddedAccountItem
accountId={accountId} accountId={account_id}
pending={state === 'pending'}
onRemove={handleRemoveAccountItem} onRemove={handleRemoveAccountItem}
/> />
</Article> </Article>
@ -429,7 +453,7 @@ export const CollectionAccounts: React.FC<{
</Scrollable> </Scrollable>
</div> </div>
</FormStack> </FormStack>
{!isEditMode && hasAccounts && ( {!isEditMode && hasItems && (
<div className={classes.stickyFooter}> <div className={classes.stickyFooter}>
<Button type='submit'> <Button type='submit'>
{id ? ( {id ? (

View File

@ -46,7 +46,7 @@ import { WizardStepTitle } from './wizard_step_title';
export const CollectionDetails: React.FC = () => { export const CollectionDetails: React.FC = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const history = useHistory(); const history = useHistory();
const { id, name, description, topic, discoverable, sensitive, accountIds } = const { id, name, description, topic, discoverable, sensitive, items } =
useAppSelector((state) => state.collections.editor); useAppSelector((state) => state.collections.editor);
const handleNameChange = useCallback( const handleNameChange = useCallback(
@ -123,7 +123,7 @@ export const CollectionDetails: React.FC = () => {
description, description,
discoverable, discoverable,
sensitive, sensitive,
account_ids: accountIds, account_ids: items.map((item) => item.account_id),
}; };
if (topic) { if (topic) {
payload.tag_name = topic; payload.tag_name = topic;
@ -152,7 +152,7 @@ export const CollectionDetails: React.FC = () => {
sensitive, sensitive,
dispatch, dispatch,
history, history,
accountIds, items,
currentUserName, currentUserName,
], ],
); );

View File

@ -16,6 +16,7 @@ import type {
ApiCollectionJSON, ApiCollectionJSON,
ApiCreateCollectionPayload, ApiCreateCollectionPayload,
ApiUpdateCollectionPayload, ApiUpdateCollectionPayload,
CollectionAccountItem,
} from '@/flavours/glitch/api_types/collections'; } from '@/flavours/glitch/api_types/collections';
import { me } from '@/flavours/glitch/initial_state'; import { me } from '@/flavours/glitch/initial_state';
import { import {
@ -41,6 +42,16 @@ interface CollectionState {
editor: EditorState; editor: EditorState;
} }
/**
* This is a subset of the `CollectionAccountItem` type
* for use in the editor. Here, `account_id` is always defined
* and `state` is more limited.
*/
export interface EditorCollectionItem {
account_id: string;
state: 'pending' | 'accepted';
}
interface EditorState { interface EditorState {
id: string | null; id: string | null;
name: string; name: string;
@ -49,7 +60,7 @@ interface EditorState {
language: string | null; language: string | null;
discoverable: boolean; discoverable: boolean;
sensitive: boolean; sensitive: boolean;
accountIds: string[]; items: EditorCollectionItem[];
} }
interface UpdateEditorFieldPayload<K extends keyof EditorState> { interface UpdateEditorFieldPayload<K extends keyof EditorState> {
@ -68,7 +79,7 @@ const initialState: CollectionState = {
language: null, language: null,
discoverable: true, discoverable: true,
sensitive: false, sensitive: false,
accountIds: [], items: [],
}, },
}; };
@ -87,7 +98,7 @@ const collectionSlice = createSlice({
language: collection?.language ?? '', language: collection?.language ?? '',
discoverable: collection?.discoverable ?? true, discoverable: collection?.discoverable ?? true,
sensitive: collection?.sensitive ?? false, sensitive: collection?.sensitive ?? false,
accountIds: getCollectionItemIds(collection?.items ?? []), items: getEditorCollectionItems(collection?.items ?? []),
}; };
}, },
reset(state) { reset(state) {
@ -338,7 +349,12 @@ export const selectAccountCollections = createAppSelector(
}, },
); );
const onlyExistingIds = (id?: string): id is string => !!id; const isEditorItem = (
item: Partial<CollectionAccountItem>,
): item is EditorCollectionItem =>
!!item.account_id && (item.state === 'accepted' || item.state === 'pending');
export const getCollectionItemIds = (items?: ApiCollectionJSON['items']) => export const getEditorCollectionItems = (items?: CollectionAccountItem[]) =>
items?.map((item) => item.account_id).filter(onlyExistingIds) ?? []; items
?.map(({ account_id, state }) => ({ account_id, state }))
.filter(isEditorItem) ?? [];