✨(frontend) add search input in the header

Allow the user to search in their email through a search input
This commit is contained in:
jbpenrath
2025-05-14 16:58:43 +02:00
committed by Jean-Baptiste PENRATH
parent b887e926c7
commit 37d689bcb0
24 changed files with 662 additions and 29 deletions
+2 -2
View File
@@ -275,8 +275,8 @@ class ThreadViewSet(
es_filters[f"is_{field_name}"] = False
# Get page parameters
page = self.paginator.get_page_number(request, self)
page_size = self.paginator.get_page_size(request)
page = int(self.paginator.get_page_number(request, self))
page_size = int(self.paginator.get_page_size(request))
# Get search results from Elasticsearch
results = search_threads(
@@ -0,0 +1,41 @@
.search {
width: 100%;
display: flex;
align-items: center;
background-color: var(--c--theme--colors--greyscale-050);
border-radius: var(--c--theme--spacings--3xs);
border: 1px solid var(--c--theme--colors--greyscale-200);
max-width: 680px;
}
.search:focus-within {
outline: 5px auto Highlight;
outline: 5px auto -webkit-focus-ring-color;
> .search__input {
outline: none;
}
}
.search__label {
padding-left: var(--c--theme--spacings--sm);
display: grid;
place-items: center;
cursor: text;
}
.search__input {
appearance: none;
border: none;
background-color: transparent;
padding: var(--c--theme--spacings--sm);
flex: 1;
width: 100%;
height: 100%;
}
@media screen and (max-width: 1024px) {
.search{
max-width: 100%;
}
}
@@ -0,0 +1,60 @@
import { useRouter } from "next/router";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "next/navigation";
import { useEffect, useState, useRef } from "react";
export const SearchInput = () => {
const router = useRouter();
const searchParams = useSearchParams();
const [value, setValue] = useState<string>(searchParams.get('search') || '');
const { t } = useTranslation();
const isUserTyping = useRef(false);
/**
* Each time the user types, we update the URL with the new search query.
*/
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
const query = event.target.value;
isUserTyping.current = true;
setValue(query);
const url = new URL(router.asPath, 'http://localhost');
if (query) {
url.searchParams.set('search', query);
} else {
url.searchParams.delete('search');
}
router.replace(url.pathname + url.search, undefined, { shallow: true });
}
/**
* Each time the URL changes, we update the search query
* except when the user is typing to prevent the cursor from jumping
* to the end of the input.
*/
useEffect(() => {
// Only update the value from searchParams if the user is not currently typing
if (!isUserTyping.current) {
setValue(searchParams.get('search') || '');
}
isUserTyping.current = false;
}, [searchParams]);
return (
<div className="search">
<label className="search__label" htmlFor="search">
<span className="material-icons">search</span>
<span className="c__offscreen">{t("search.placeholder")}</span>
</label>
<input
className="search__input"
id="search"
type="search"
value={value}
onChange={handleSearch}
placeholder={t("search.placeholder")}
/>
</div>
);
}
@@ -82,6 +82,9 @@
},
"message_editor": {
"start_typing": "Start typing..."
},
"search": {
"placeholder": "Search..."
}
}
},
@@ -170,6 +173,9 @@
},
"message_editor": {
"start_typing": "Commencer à écrire..."
},
"search": {
"placeholder": "Rechercher..."
}
}
}
@@ -1,6 +1,6 @@
nav {
width: 100%;
height: 52px;
height: var(--header-height);
background-color: #fff;
border: 1px grey solid;
display: flex;
@@ -0,0 +1,50 @@
.c__header {
--header-height: 52px;
display: flex;
background-color: var(--c--theme--colors--greyscale-000);
border-bottom: 1px solid var(--c--theme--colors--greyscale-200);
justify-content: space-between;
align-items: center;
height: var(--header-height);
min-height: var(--header-height);
max-height: var(--header-height);
padding: 0 1.125rem;
@media screen and (max-width: 1024px) {
padding: 0 1rem;
}
&__toggle-menu {
display: none;
@media screen and (max-width: 1024px) {
display: block;
}
&__icon {
color: var(--c--theme--colors--primary-800);
}
}
&__left,
&__center,
&__right {
display: flex;
align-items: center;
}
&__center {
background: red;
}
&__right {
display: flex;
align-items: center;
&__language-picker {
@media screen and (max-width: 1024px) {
display: none;
}
}
}
}
@@ -1,5 +0,0 @@
.c__header-right {
display: flex;
align-items: center;
gap: 1rem;
}
@@ -18,7 +18,7 @@ export const HeaderRight = () => {
const [isOpen, setIsOpen] = useState(false);
const { t } = useTranslation();
return (
<div className="c__header-right">
<>
{user && (
<DropdownMenu
options={[
@@ -41,12 +41,12 @@ export const HeaderRight = () => {
}
iconPosition="right"
>
{t("my_account")}
<span className="text-nowrap">{t("my_account")}</span>
</Button>
</DropdownMenu>
)}
<LanguagePicker />
</div>
</>
);
};
@@ -111,6 +111,7 @@ const FolderItem = ({ folder }: FolderItemProps) => {
return (
<Link
href={`/mailbox/${selectedMailbox?.id}?${queryParams}`}
shallow={false}
className={clsx("mailbox__item", {
"mailbox__item--active": isActive
})}
@@ -7,7 +7,7 @@
place-items: center;
justify-content: center;
text-align: center;
height: calc(100vh - 52px);
height: calc(100vh - var(--header-height));
& > div {
font-size: var(--c--theme--font--sizes--h4);
@@ -0,0 +1,58 @@
.c__header {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-areas: "left center right";
background-color: var(--c--theme--colors--greyscale-000);
border-bottom: 1px solid var(--c--theme--colors--greyscale-200);
height: var(--header-height);
min-height: var(--header-height);
max-height: var(--header-height);
padding: 0 1.125rem;
@media screen and (max-width: 1024px) {
grid-template-columns: auto 1fr 1fr;
grid-template-rows: 1fr 1fr;
grid-template-areas:
"toggle left right"
"center center center";
padding: 0 1rem;
}
&__toggle-menu {
display: none;
grid-area: toggle;
@media screen and (max-width: 1024px) {
display: block;
}
&__icon {
color: var(--c--theme--colors--primary-800);
}
}
&__left,
&__center,
&__right {
display: flex;
align-items: center;
}
&__left {
grid-area: left;
}
&__center {
grid-area: center;
}
&__right {
grid-area: right;
justify-content: flex-end;
&__language-picker {
@media screen and (max-width: 1024px) {
display: none;
}
}
}
}
@@ -0,0 +1,52 @@
import { SearchInput } from "@/features/forms/components/search-input";
import { DropdownMenuOption, LanguagePicker } from "@gouvfr-lasuite/ui-kit";
import { Button, useCunningham } from "@openfun/cunningham-react";
export type HeaderProps = {
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
languages?: DropdownMenuOption[];
onTogglePanel?: () => void;
isPanelOpen?: boolean;
};
export const Header = ({
leftIcon,
rightIcon,
languages,
onTogglePanel,
isPanelOpen,
}: HeaderProps) => {
const { t } = useCunningham();
return (
<div className="c__header">
<div className="c__header__toggle-menu">
<Button
size="medium"
onClick={onTogglePanel}
aria-label={isPanelOpen ? t("Close the menu") : t("Open the menu")}
color="tertiary-text"
icon={
<span className="material-icons clr-primary-800">
{isPanelOpen ? "close" : "menu"}
</span>
}
/>
</div>
<div className="c__header__left">{leftIcon}</div>
<div className="c__header__center">
<SearchInput />
</div>
<div className="c__header__right">
{languages && (
<div className="c__header__right__language-picker">
<LanguagePicker languages={languages} />
</div>
)}
{rightIcon}
</div>
</div>
);
};
@@ -0,0 +1,42 @@
import React, { useEffect } from "react";
// TODO: This hook is from Cunningham, it should be exporter from it.
/**
* This hook is used to create a state that can be controlled by the parent. If not the state is handled internally.
*
* @param defaultValue if not controlled by the parent, this is the default value
* @param propsValue if controlled by the parent, this is the value
* @param propsCallback if controlled by the parent, this is the callback to call when the value changes
*/
export const useControllableState = <T>(
defaultValue: T,
propsValue?: T,
propsCallback?: (value: T) => void
): [T, (value: T) => void] => {
const [state, setState] = React.useState(
typeof propsValue === "undefined" ? defaultValue : propsValue
);
// Bottom-Up.
const onChange = (value: T) => {
if (propsCallback) {
propsCallback(value);
} else {
setState(value);
}
};
// Top-Down.
useEffect(() => {
if (!propsCallback) {
return;
}
if (typeof propsValue !== "undefined" && propsValue !== state) {
setState(propsValue);
}
}, [propsValue]);
return [state, onChange];
};
@@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
import config from "@/../cunningham";
export const useResponsive = () => {
const [width, setWidth] = useState(window.innerWidth);
const mobile = parseInt(
config.themes.default.theme.breakpoints.mobile.replace("px", "")
);
const tablet = parseInt(
config.themes.default.theme.breakpoints.tablet.replace("px", "")
);
const handleWindowSizeChange = () => {
setWidth(window.innerWidth);
};
useEffect(() => {
window.addEventListener("resize", handleWindowSizeChange);
return () => {
window.removeEventListener("resize", handleWindowSizeChange);
};
}, []);
const isMobile = width <= mobile;
const isTablet = width <= tablet;
const isDesktop = width > tablet;
return { isMobile, isTablet, isDesktop };
};
@@ -1,4 +1,4 @@
import { MainLayout as KitMainLayout } from "@gouvfr-lasuite/ui-kit";
import { AppLayout } from "./layout";
import { Header, HeaderRight } from "../header";
import { MailboxPanel } from "@/features/layouts/components/mailbox-panel";
import { PropsWithChildren } from "react";
@@ -34,13 +34,15 @@ const MainLayoutContent = ({ children }: PropsWithChildren) => {
<NoMailbox />
</>
) : (
<KitMainLayout
enableResize
leftPanelContent={<MailboxPanel />}
icon={<img src="/images/app-logo.svg" alt="logo" height={32} />}
rightHeaderContent={<HeaderRight />}
>
<>
<AppLayout
enableResize
leftPanelContent={<MailboxPanel />}
icon={<img src="/images/app-logo.svg" alt="logo" height={32} />}
rightHeaderContent={<HeaderRight />}
>
{children}
</KitMainLayout>
</AppLayout>
</>
)
}
@@ -0,0 +1,39 @@
.c__main-layout {
display: flex;
flex-direction: column;
height: 100dvh;
}
.c__main-layout__header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
}
.c__main-layout__content {
width: 100%;
height: 100%;
display: flex;
margin-top: var(--header-height);
&__left-panel {
height: calc(100dvh - var(--header-height));
width: 300px;
border-right: 1px solid var(--c--theme--colors--greyscale-200);
@media screen and (max-width: 1024px) {
width: 100dvw;
}
}
&__center {
display: flex;
&__children {
height: calc(100dvh - var(--header-height));
width: 100%;
flex: 1;
overflow-y: auto;
}
}
}
@@ -0,0 +1,158 @@
import { PropsWithChildren, useEffect, useRef, useState } from "react";
import { Header } from "../header";
import { useResponsive } from "../hooks/useResponsive";
import {
ImperativePanelHandle,
Panel,
PanelGroup,
PanelResizeHandle,
} from "react-resizable-panels";
import { DropdownMenuOption,LeftPanel } from "@gouvfr-lasuite/ui-kit";
import { RightPanel } from "../right-panel";
import { useControllableState } from "../hooks/useControllableState";
export type MainLayoutProps = {
icon?: React.ReactNode;
leftPanelContent?: React.ReactNode;
rightPanelContent?: React.ReactNode;
rightHeaderContent?: React.ReactNode;
languages?: DropdownMenuOption[];
onToggleRightPanel?: () => void;
enableResize?: boolean;
rightPanelIsOpen?: boolean;
hideLeftPanelOnDesktop?: boolean;
isLeftPanelOpen?: boolean;
setIsLeftPanelOpen?: (isLeftPanelOpen: boolean) => void;
};
/**
* This component is a copy of the MainLayout component from our ui-kit.
*
* @TODO: Remove this and update the ui-kit to be able to render panels without header or
* add props to fully override the header.
*/
export const AppLayout = ({
icon,
children,
hideLeftPanelOnDesktop = false,
leftPanelContent,
rightPanelContent,
rightHeaderContent,
languages,
enableResize = false,
rightPanelIsOpen = false,
...props
}: PropsWithChildren<MainLayoutProps>) => {
const [isLeftPanelOpen, setIsLeftPanelOpen] = useControllableState(
false,
props.isLeftPanelOpen,
props.setIsLeftPanelOpen
);
const { isDesktop } = useResponsive();
const ref = useRef<ImperativePanelHandle>(null);
const [minPanelSize, setMinPanelSize] = useState(
calculateDefaultSize(300, isDesktop)
);
const [maxPanelSize, setMaxPanelSize] = useState(
calculateDefaultSize(450, isDesktop)
);
const onTogglePanel = () => {
setIsLeftPanelOpen(!isLeftPanelOpen);
};
useEffect(() => {
const updatePanelSize = () => {
const min = Math.round(calculateDefaultSize(300, isDesktop));
const max = Math.round(
Math.min(calculateDefaultSize(450, isDesktop), 40)
);
setMinPanelSize(isDesktop ? min : 0);
if (enableResize) {
setMaxPanelSize(max);
} else {
setMaxPanelSize(min);
}
};
updatePanelSize();
window.addEventListener("resize", () => {
updatePanelSize();
});
return () => {
window.removeEventListener("resize", updatePanelSize);
};
}, [isDesktop, enableResize]);
// We need to have two different states for the left panel, we want to always keep the
// left panel mounted on mobile in order to show the animation when it opens or closes, instead
// of abruptly disappearing when closing the panel.
// On desktop, we want to hide the left panel when the prop is set to true, so we need to
// completely unmount it as it will never be visible.
const mountLeftPanel = isDesktop ? !hideLeftPanelOnDesktop : true;
const showLeftPanel = isDesktop ? !hideLeftPanelOnDesktop : isLeftPanelOpen;
return (
<div className="c__main-layout">
<div className="c__main-layout__header">
<Header
onTogglePanel={onTogglePanel}
isPanelOpen={isLeftPanelOpen}
rightIcon={rightHeaderContent}
leftIcon={icon}
languages={languages}
/>
</div>
<div className="c__main-layout__content">
<PanelGroup autoSaveId={"persistance"} direction="horizontal">
{mountLeftPanel && (
<>
<Panel
ref={ref}
order={0}
defaultSize={minPanelSize}
minSize={minPanelSize}
maxSize={maxPanelSize}
>
<LeftPanel isOpen={showLeftPanel}>{leftPanelContent}</LeftPanel>
</Panel>
{isDesktop && (
<PanelResizeHandle
className="bg-greyscale-200"
style={{
width: "1px",
}}
/>
)}
</>
)}
<Panel order={1}>
<div className="c__main-layout__content__center">
<div className="c__main-layout__content__center__children">
{children}
</div>
<RightPanel isOpen={rightPanelIsOpen}>
{rightPanelContent}
</RightPanel>
</div>
</Panel>
</PanelGroup>
</div>
</div>
);
};
const calculateDefaultSize = (targetWidth: number, isDesktop: boolean) => {
if (!isDesktop) {
return 0;
}
const windowWidth = window.innerWidth;
return (targetWidth / windowWidth) * 100;
};
@@ -0,0 +1,27 @@
.c__right-panel {
height: calc(100dvh - var(--header-height));
width: 0;
border-left: 1px solid var(--c--theme--colors--greyscale-200);
transition: width 0.15s ease-in-out;
overflow-y: auto;
&.open {
width: 300px;
}
}
.c__right-panel__mobile {
z-index: 998;
width: 100dvw;
height: calc(100dvh - var(--header-height));
overflow-y: auto;
position: fixed;
background-color: var(--c--theme--colors--greyscale-000);
transition: transform 0.15s ease-in-out;
transform: translateX(100dvw);
&.open {
transform: translateX(0);
}
}
@@ -0,0 +1,25 @@
import { useResponsive } from "../hooks/useResponsive";
import clsx from "clsx";
import { PropsWithChildren } from "react";
export type RightPanelProps = {
isOpen?: boolean;
};
export const RightPanel = ({
children,
isOpen,
}: PropsWithChildren<RightPanelProps>) => {
const { isDesktop } = useResponsive();
if (!isDesktop) {
return (
<div className={clsx("c__right-panel__mobile", { open: isOpen })}>
{children}
</div>
);
}
return (
<div className={clsx("c__right-panel", { open: isOpen })}>{children}</div>
);
};
@@ -1,9 +1,21 @@
.thread-view {
background-color: var(--c--theme--colors--greyscale-050);
display: flex;
flex-direction: column;
max-height: 100%;
}
@media screen and (max-width: 1024px) {
.thread-view {
position: fixed;
top: var(--header-height);
left: 0;
right: 0;
bottom: 0;
z-index: 900;
}
}
.thread-view--loading {
display: flex;
align-items: center;
@@ -5,6 +5,7 @@ import { useRouter } from "next/router";
import usePrevious from "@/hooks/use-previous";
import { useSearchParams } from "next/navigation";
import { DEFAULT_FOLDERS } from "../layouts/components/mailbox-panel/components/mailbox-list";
import { useDebounceCallback } from "@/hooks/use-debounce-callback";
type QueryState = {
status: QueryStatus,
@@ -94,6 +95,10 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
const queryClient = useQueryClient();
const router = useRouter();
const searchParams = useSearchParams();
const previousSearchParams = usePrevious(searchParams);
const hasSearchParamsChanged = useMemo(() => {
return previousSearchParams?.toString() !== searchParams.toString();
}, [previousSearchParams, searchParams]);
const [selectedMailbox, setSelectedMailbox] = useState<Mailbox | null>(null);
const [selectedThread, setSelectedThread] = useState<Thread | null>(null);
const mailboxQuery = useMailboxesList({
@@ -103,11 +108,18 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
});
const previousUnreadMessagesCount = usePrevious(selectedMailbox?.count_unread_messages || 0);
const threadQueryKey = useMemo(() => {
const queryKey = ['threads', selectedMailbox?.id];
if (searchParams.get('search')) {
return [...queryKey, 'search'];
}
return [...queryKey, searchParams.toString()];
}, [selectedMailbox?.id, searchParams]);
const threadsQuery = useThreadsListInfinite(undefined, {
query: {
enabled: !!selectedMailbox,
initialPageParam: 1,
queryKey: ['threads', selectedMailbox?.id, searchParams.toString()],
queryKey: threadQueryKey,
getNextPageParam: (lastPage, pages) => {
return pages.length + 1;
},
@@ -157,6 +169,11 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
await queryClient.invalidateQueries({ queryKey: ['messages', selectedThread.id] });
}
}
const resetSearchQueryDebounced = useDebounceCallback(() => {
queryClient.resetQueries(
{ predicate: ({ queryKey}) => queryKey.includes('search') },
);
}, 500);
const invalidateThreadsStats = async () => {
await queryClient.invalidateQueries({ queryKey: ['threads', 'stats', selectedMailbox?.id] });
@@ -167,7 +184,7 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
*/
const unselectThread = () => {
setSelectedThread(null);
if (router.query.threadId) {
if (selectedMailbox && router.query.threadId) {
router.push(`/mailbox/${selectedMailbox!.id}?${searchParams}`);
}
}
@@ -270,6 +287,13 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
}
}, [selectedMailbox?.count_unread_messages]);
useEffect(() => {
if (searchParams.get('search') !== previousSearchParams?.get('search')) {
resetSearchQueryDebounced();
}
unselectThread();
}, [hasSearchParamsChanged])
return <MailboxContext.Provider value={context}>{children}</MailboxContext.Provider>
};
-4
View File
@@ -4,8 +4,6 @@
$tablet: map.get($themes, "default", "theme", "breakpoints", "tablet");
.app__home {
--header-height: 52px;
.c__header {
height: var(--header-height);
min-height: var(--header-height);
@@ -31,8 +29,6 @@ $tablet: map.get($themes, "default", "theme", "breakpoints", "tablet");
}
@media (max-width: $tablet) {
--header-height: 52px;
.c__header {
.drive__header__left {
img:first-child {
+12 -1
View File
@@ -1,5 +1,12 @@
:root {
font-size: 14px;
--header-height: 52px;
}
@media screen and (max-width: 1024px) {
:root {
--header-height: 104px;
}
}
body {
@@ -108,4 +115,8 @@ body {
.m-base {
margin: var(--c--theme--spacings--base);
}
}
.text-nowrap {
white-space: nowrap;
}
+6 -2
View File
@@ -5,10 +5,14 @@
@use "./cunningham-tokens.css";
@use "./globals.scss";
// UI Kit components fork
@use "./../features/layouts/components/main/right-panel";
@use "./../features/layouts/components/main/header";
@use "./../features/layouts/components/main/layout";
// Application Components
@use "./../features/layouts/components/global/global-layout.scss";
@use "./../features/layouts/components/main";
@use "./../features/layouts/components/header";
// @use "./../features/layouts/components/header";
@use "./../features/ui/components/users";
@use "./../features/ui/components/breadcrumbs";
@use "./../features/ui/components/toaster";
@@ -26,7 +30,7 @@
@use "./../features/layouts/components/thread-view/components/message-reply-form";
@use "./../features/forms/components/message-form";
@use "./../features/forms/components/message-editor";
@use "./../features/forms/components/search-input";
// Application views
@use "./../pages";
@use "./../pages/mailbox";