Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 23 additions & 12 deletions packages/shared/src/components/PostsSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,16 @@ import { PopoverContent } from './popover/Popover';
import { SearchIcon } from './icons';
import { StaleTime } from '../lib/query';

const SEARCH_TYPES = {
searchPostSuggestions: SEARCH_POST_SUGGESTIONS,
searchBookmarksSuggestions: SEARCH_BOOKMARKS_SUGGESTIONS,
searchReadingHistorySuggestions: SEARCH_READING_HISTORY_SUGGESTIONS,
};

export type PostsSearchProps = {
initialQuery?: string;
placeholder?: string;
suggestionType?: string;
suggestionType?: keyof typeof SEARCH_TYPES;
autoFocus?: boolean;
className?: string;
onSubmitQuery: (
Expand All @@ -35,14 +41,15 @@ export type PostsSearchProps = {
},
) => Promise<unknown>;
onClearQuery?: () => Promise<unknown>;
/**
* Autocomplete suggestions (Mimir-backed) are not scoped to the current
* feed/source. Surfaces that search within a narrower scope (e.g. a single
* squad) should opt out rather than show suggestions from the wrong scope.
* Defaults to `true` to preserve existing behavior.
*/
enableSuggestions?: boolean;
} & Pick<HTMLAttributes<HTMLInputElement>, 'onFocus'>;

const SEARCH_TYPES = {
searchPostSuggestions: SEARCH_POST_SUGGESTIONS,
searchBookmarksSuggestions: SEARCH_BOOKMARKS_SUGGESTIONS,
searchReadingHistorySuggestions: SEARCH_READING_HISTORY_SUGGESTIONS,
};

export default function PostsSearch({
initialQuery: initialQueryProp,
autoFocus = true,
Expand All @@ -52,14 +59,18 @@ export default function PostsSearch({
suggestionType = 'searchPostSuggestions',
onFocus,
onClearQuery,
enableSuggestions = true,
}: PostsSearchProps): ReactElement {
const { time, contentCurationFilter } = useSearchContextProvider();
const searchBoxRef = useRef<HTMLDivElement>();
const searchBoxRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState<string>();
const [items, setItems] = useState<string[]>([]);
const { value: searchVersion } = useConditionalFeature({
feature: feature.searchVersion,
shouldEvaluate: !!query && suggestionType === 'searchPostSuggestions',
shouldEvaluate:
!!query &&
suggestionType === 'searchPostSuggestions' &&
enableSuggestions,
});
const SEARCH_URL = SEARCH_TYPES[suggestionType];
const purify = useDomPurify();
Expand All @@ -72,7 +83,7 @@ export default function PostsSearch({
queryKey: [suggestionType, query],
queryFn: () =>
gqlClient.request(SEARCH_URL, { query, version: searchVersion }),
enabled: !!query,
enabled: !!query && enableSuggestions,
staleTime: StaleTime.Default,
});

Expand All @@ -88,7 +99,7 @@ export default function PostsSearch({

const submitQuery = async (item?: string) => {
const itemQuery = item?.replace?.(sanitizeSearchTitleMatch, '');
await onSubmitQuery(itemQuery || query, {
await onSubmitQuery(itemQuery || query || '', {
filters: {
time: time.toString(),
contentCuration: contentCurationFilter,
Expand Down Expand Up @@ -126,7 +137,7 @@ export default function PostsSearch({

useEffect(() => {
if (autoFocus) {
searchBoxRef.current?.querySelector('input').focus();
searchBoxRef.current?.querySelector('input')?.focus();
}
}, [searchBoxRef, autoFocus]);

Expand Down
45 changes: 36 additions & 9 deletions packages/shared/src/components/squads/SquadFeedHeading.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ReactElement } from 'react';
import type { ReactElement, ReactNode } from 'react';
import React, { useContext, useMemo } from 'react';
import { Button, ButtonVariant } from '../buttons/Button';
import { PinIcon } from '../icons';
Expand All @@ -8,9 +8,14 @@ import { useSquadActions } from '../../hooks';

interface SquadFeedHeadingProps {
squad: Squad;
/** Optional search field rendered at the start of the heading row. */
searchChildren?: ReactNode;
}

function SquadFeedHeading({ squad }: SquadFeedHeadingProps): ReactElement {
function SquadFeedHeading({
squad,
searchChildren,
}: SquadFeedHeadingProps): ReactElement {
const { items } = useContext(ActiveFeedContext);
const { collapseSquadPinnedPosts, expandSquadPinnedPosts } = useSquadActions({
squad,
Expand All @@ -20,9 +25,17 @@ function SquadFeedHeading({ squad }: SquadFeedHeadingProps): ReactElement {
const isSquadMember = !!squad.currentMember;

const onClick = async () => {
return collapsePinnedPosts
? await expandSquadPinnedPosts(squad.id)
: await collapseSquadPinnedPosts(squad.id);
const togglePinnedPosts = collapsePinnedPosts
? expandSquadPinnedPosts
: collapseSquadPinnedPosts;

if (!togglePinnedPosts || !squad.id) {
throw new Error(
'SquadFeedHeading: pinned posts toggle requires a squad id and mutation',
);
}

return togglePinnedPosts(squad.id);
};

const pinnedPostsCount = useMemo(
Expand All @@ -38,8 +51,22 @@ function SquadFeedHeading({ squad }: SquadFeedHeadingProps): ReactElement {

return (
<div className="flex w-full flex-row flex-wrap items-center justify-end gap-4 px-6 pb-6 laptop:px-0">
<span className="ml-auto flex flex-row gap-3 border-l border-border-subtlest-tertiary pl-3">
{isSquadMember && (
{/* Mirrors the bookmarks CustomFeedHeader idiom: the search field fills
the row and the action buttons sit directly after it, so there is no
dead gap between the two. */}
{searchChildren && (
<div className="flex min-w-[12rem] flex-1 items-center">
{searchChildren}
</div>
)}
{isSquadMember && (
<span
className={
searchChildren
? 'flex flex-row gap-3'
: 'ml-auto flex flex-row gap-3 border-l border-border-subtlest-tertiary pl-3'
}
>
<Button
variant={ButtonVariant.Float}
onClick={onClick}
Expand All @@ -49,8 +76,8 @@ function SquadFeedHeading({ squad }: SquadFeedHeadingProps): ReactElement {
? `Show pinned posts (${pinnedPostsCount})`
: 'Hide pinned posts'}
</Button>
)}
</span>
</span>
)}
</div>
);
}
Expand Down
24 changes: 24 additions & 0 deletions packages/shared/src/graphql/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,30 @@ export const SEARCH_BOOKMARKS_QUERY = gql`
${FEED_POST_CONNECTION_FRAGMENT}
`;

export const SEARCH_SOURCE_POSTS_QUERY = gql`
query SearchSourcePosts(
$loggedIn: Boolean! = false
$first: Int
$after: String
$source: ID!
$query: String!
$supportedTypes: [String!]
$version: Int
) {
page: searchSourcePosts(
first: $first
after: $after
source: $source
query: $query
supportedTypes: $supportedTypes
version: $version
) {
...FeedPostConnection
}
}
${FEED_POST_CONNECTION_FRAGMENT}
`;

export const SEARCH_BOOKMARKS_SUGGESTIONS = gql`
query SearchBookmarksSuggestions($query: String!) {
searchBookmarksSuggestions(query: $query) {
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/lib/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export enum OtherFeedPage {
BookmarkLater = 'bookmarkslater',
BookmarkFolder = 'bookmarks[folderId]',
SearchBookmarks = 'search-bookmarks',
SearchSquad = 'search-squad',
Preview = 'preview',
Author = 'author',
UserUpvoted = 'user-upvoted',
Expand Down
150 changes: 139 additions & 11 deletions packages/webapp/pages/squads/[handle]/index.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
import type { ParsedUrlQuery } from 'querystring';
import type { ReactElement } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { NextSeoProps } from 'next-seo';
import Head from 'next/head';
import Feed from '@dailydotdev/shared/src/components/Feed';
import type { FeedProps } from '@dailydotdev/shared/src/components/Feed';
import { BellIcon } from '@dailydotdev/shared/src/components/icons';
import {
SEARCH_SOURCE_POSTS_QUERY,
SOURCE_FEED_QUERY,
supportedTypesForPrivateSources,
} from '@dailydotdev/shared/src/graphql/feed';
import { SearchProviderEnum } from '@dailydotdev/shared/src/graphql/search';
import { useFeaturesReadyContext } from '@dailydotdev/shared/src/components/GrowthBookProvider';
import { feature } from '@dailydotdev/shared/src/lib/featureManagement';
import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext';
import { SquadPageHeader } from '@dailydotdev/shared/src/components/squads/SquadPageHeader';
import { SquadHeaderBar } from '@dailydotdev/shared/src/components/squads/SquadHeaderBar';
Expand Down Expand Up @@ -98,6 +109,21 @@ const SquadEmptyScreen = dynamic(
),
);

const SearchEmptyScreen = dynamic(
() =>
import(
/* webpackChunkName: "searchEmptyScreen" */ '@dailydotdev/shared/src/components/SearchEmptyScreen'
),
);

const PostsSearch = dynamic(
() =>
import(
/* webpackChunkName: "postsSearch" */ '@dailydotdev/shared/src/components/PostsSearch'
),
{ ssr: false },
);

const SquadLoading = dynamic(
() =>
import(
Expand Down Expand Up @@ -360,6 +386,101 @@ const SquadPage = ({
[squadId],
);

const searchQuery =
typeof router.query?.q === 'string' ? router.query.q.trim() : '';
const isSearching = searchQuery.length > 0;
const { getFeatureValue } = useFeaturesReadyContext();

const feedProps = useMemo<FeedProps<unknown>>(() => {
if (isSearching) {
return {
feedName: OtherFeedPage.SearchSquad,
feedQueryKey: [
'searchSourcePosts',
user?.id ?? 'anonymous',
squadId,
searchQuery,
],
query: SEARCH_SOURCE_POSTS_QUERY,
variables: {
source: squadId,
query: searchQuery,
supportedTypes: supportedTypesForPrivateSources,
version: getFeatureValue(feature.searchVersion),
},
emptyScreen: <SearchEmptyScreen />,
};
}

return {
feedName: OtherFeedPage.Squads,
feedQueryKey: [
'sourceFeed',
user?.id ?? 'anonymous',
Object.values(queryVariables),
],
query: SOURCE_FEED_QUERY,
variables: queryVariables,
emptyScreen: <SquadEmptyScreen />,
};
}, [
isSearching,
searchQuery,
squadId,
queryVariables,
user?.id,
getFeatureValue,
]);

// Search submit/clear write `q` to the URL. Cannot reuse `router.pathname`
// here (`/squads/[handle]`) the way RouterPostsSearch does for static
// routes — Next's router.replace throws/produces a literal
// "/squads/[handle]" href when the dynamic segment isn't present in the
// provided query object, so we build the concrete path from `asPath`.
const onSubmitSquadSearch = useCallback(
(query: string) => {
logEvent({
event_name: LogEvent.SubmitSearch,
extra: JSON.stringify({
query,
provider: SearchProviderEnum.Posts,
squad: squadId,
}),
});

const basePath = router.asPath.split('?')[0];
const searchParams = new URLSearchParams(window.location.search);
if (query) {
searchParams.set('q', query);
} else {
searchParams.delete('q');
}

return router.replace(
getPathnameWithQuery(basePath, searchParams),
undefined,
{ shallow: true },
);
},
[logEvent, router, squadId],
);

const onClearSquadSearch = useCallback(() => {
const basePath = router.asPath.split('?')[0];
const searchParams = new URLSearchParams(window.location.search);
searchParams.delete('q');

return router.replace(
getPathnameWithQuery(basePath, searchParams),
undefined,
{ shallow: true },
);
}, [router]);

const onFocusSquadSearch = useCallback(() => {
logEvent({ event_name: LogEvent.FocusSearch });
}, [logEvent]);

useEffect(() => {
if (!isForbidden) {
return;
Expand Down Expand Up @@ -464,18 +585,25 @@ const SquadPage = ({
<FeedPageComponent>
<Feed
className={classNames(shouldUseListFeedLayout ? 'px-0' : 'px-6')}
feedName={OtherFeedPage.Squads}
feedQueryKey={[
'sourceFeed',
user?.id ?? 'anonymous',
Object.values(queryVariables),
]}
query={SOURCE_FEED_QUERY}
variables={queryVariables}
{...feedProps}
showSearch={false}
emptyScreen={<SquadEmptyScreen />}
options={{ refetchOnMount: true }}
header={<SquadFeedHeading squad={squad} />}
header={
<SquadFeedHeading
squad={squad}
searchChildren={
<PostsSearch
autoFocus={false}
enableSuggestions={false}
placeholder="Search this squad"
initialQuery={searchQuery}
onSubmitQuery={onSubmitSquadSearch}
onClearQuery={onClearSquadSearch}
onFocus={onFocusSquadSearch}
/>
}
/>
}
inlineHeader
allowPin
/>
Expand Down
Loading