Skip to content
Snippets Groups Projects
Unverified Commit 92d10d40 authored by Alexander Polyankin's avatar Alexander Polyankin Committed by GitHub
Browse files

Add events search (#20629)

parent 38153250
Branches
Tags
No related merge requests found
......@@ -28,54 +28,60 @@ type TextInputProps = {
invalid?: boolean;
} & Omit<React.HTMLProps<HTMLInputElement>, "onChange">;
export default forwardRef<HTMLInputElement, TextInputProps>(function TextInput(
{
value = "",
className,
placeholder = t`Find...`,
onChange,
hasClearButton = false,
icon,
type = "text",
colorScheme = "default",
autoFocus = false,
padding = "md",
borderRadius = "md",
invalid,
...rest
}: TextInputProps,
ref,
) {
const handleClearClick = () => {
onChange("");
};
const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
function TextInput(
{
value = "",
className,
placeholder = t`Find...`,
onChange,
hasClearButton = false,
icon,
type = "text",
colorScheme = "default",
autoFocus = false,
padding = "md",
borderRadius = "md",
invalid,
...rest
}: TextInputProps,
ref,
) {
const handleClearClick = () => {
onChange("");
};
const showClearButton = hasClearButton && value.length > 0;
const showClearButton = hasClearButton && value.length > 0;
return (
<TextInputRoot className={className}>
{icon && <IconWrapper>{icon}</IconWrapper>}
<Input
ref={ref}
colorScheme={colorScheme}
autoFocus={autoFocus}
hasClearButton={hasClearButton}
hasIcon={!!icon}
placeholder={placeholder}
value={value}
type={type}
onChange={e => onChange(e.target.value)}
padding={padding}
borderRadius={borderRadius}
invalid={invalid}
{...rest}
/>
return (
<TextInputRoot className={className}>
{icon && <IconWrapper>{icon}</IconWrapper>}
<Input
ref={ref}
colorScheme={colorScheme}
autoFocus={autoFocus}
hasClearButton={hasClearButton}
hasIcon={!!icon}
placeholder={placeholder}
value={value}
type={type}
onChange={e => onChange(e.target.value)}
padding={padding}
borderRadius={borderRadius}
invalid={invalid}
{...rest}
/>
{showClearButton && (
<ClearButton onClick={handleClearClick}>
<Icon name="close" size={12} />
</ClearButton>
)}
</TextInputRoot>
);
{showClearButton && (
<ClearButton onClick={handleClearClick}>
<Icon name="close" size={12} />
</ClearButton>
)}
</TextInputRoot>
);
},
);
export default Object.assign(TextInput, {
Input,
});
import React from "react";
import React, { memo } from "react";
import { t } from "ttag";
import { Collection, Timeline, TimelineEvent } from "metabase-types/api";
import EventCard from "../EventCard";
......@@ -51,4 +51,4 @@ const EventList = ({
);
};
export default EventList;
export default memo(EventList);
import styled from "@emotion/styled";
import Link from "metabase/core/components/Link";
import TextInput from "metabase/components/TextInput";
export const ModalRoot = styled.div`
display: flex;
......@@ -8,10 +10,25 @@ export const ModalRoot = styled.div`
export const ModalToolbar = styled.div`
display: flex;
justify-content: flex-end;
padding: 1rem 2rem 0;
`;
export const ModalToolbarInput = styled(TextInput)`
flex: 1 1 auto;
margin-right: 1rem;
${TextInput.Input} {
height: 2.5rem;
}
`;
export const ModalToolbarLink = styled(Link)`
display: flex;
flex: 0 0 auto;
align-items: center;
height: 2.5rem;
`;
export const ModalBody = styled.div`
flex: 1 1 auto;
padding: 2rem;
......
import React from "react";
import React, { useMemo, useState } from "react";
import { t } from "ttag";
import _ from "underscore";
import * as Urls from "metabase/lib/urls";
import { parseTimestamp } from "metabase/lib/time";
import Link from "metabase/core/components/Link";
import { SEARCH_DEBOUNCE_DURATION } from "metabase/lib/constants";
import * as Urls from "metabase/lib/urls";
import { useDebouncedValue } from "metabase/hooks/use-debounced-value";
import Icon from "metabase/components/Icon";
import EntityMenu from "metabase/components/EntityMenu";
import { Collection, Timeline, TimelineEvent } from "metabase-types/api";
import EventEmptyState from "../EventEmptyState";
......@@ -14,12 +16,14 @@ import {
ModalBody,
ModalRoot,
ModalToolbar,
ModalToolbarInput,
ModalToolbarLink,
} from "./TimelineDetailsModal.styled";
export interface TimelineDetailsModalProps {
timeline: Timeline;
collection: Collection;
archived?: boolean;
isArchive?: boolean;
onArchive?: (event: TimelineEvent) => void;
onUnarchive?: (event: TimelineEvent) => void;
onClose?: () => void;
......@@ -28,33 +32,53 @@ export interface TimelineDetailsModalProps {
const TimelineDetailsModal = ({
timeline,
collection,
archived = false,
isArchive = false,
onArchive,
onUnarchive,
onClose,
}: TimelineDetailsModalProps): JSX.Element => {
const title = archived ? t`Archived events` : timeline.name;
const events = getEvents(timeline.events, archived);
const hasEvents = events.length > 0;
const menuItems = getMenuItems(timeline, collection);
const title = isArchive ? t`Archived events` : timeline.name;
const [inputText, setInputText] = useState("");
const searchText = useDebouncedValue(
inputText.toLowerCase(),
SEARCH_DEBOUNCE_DURATION,
);
const events = useMemo(() => {
return getEvents(timeline.events, searchText, isArchive);
}, [timeline, searchText, isArchive]);
const menuItems = useMemo(() => {
return getMenuItems(timeline, collection);
}, [timeline, collection]);
const isNotEmpty = events.length > 0;
const isSearching = searchText.length > 0;
return (
<ModalRoot>
<ModalHeader title={title} onClose={onClose}>
{!archived && <EntityMenu items={menuItems} triggerIcon="kebab" />}
{!isArchive && <EntityMenu items={menuItems} triggerIcon="kebab" />}
</ModalHeader>
{hasEvents && (
{(isNotEmpty || isSearching) && (
<ModalToolbar>
{!archived && (
<Link
<ModalToolbarInput
value={inputText}
placeholder={t`Search for an event`}
icon={<Icon name="search" />}
onChange={setInputText}
/>
{!isArchive && (
<ModalToolbarLink
className="Button"
to={Urls.newEventInCollection(timeline, collection)}
>{t`Add an event`}</Link>
>{t`Add an event`}</ModalToolbarLink>
)}
</ModalToolbar>
)}
<ModalBody>
{hasEvents ? (
{isNotEmpty ? (
<EventList
events={events}
timeline={timeline}
......@@ -62,7 +86,7 @@ const TimelineDetailsModal = ({
onArchive={onArchive}
onUnarchive={onUnarchive}
/>
) : archived ? (
) : isArchive || isSearching ? (
<EventEmptyState />
) : (
<TimelineEmptyState timeline={timeline} collection={collection} />
......@@ -72,14 +96,29 @@ const TimelineDetailsModal = ({
);
};
const getEvents = (events: TimelineEvent[] = [], archived: boolean) => {
return _.chain(events)
.filter(e => e.archived === archived)
const getEvents = (
events: TimelineEvent[] = [],
searchText: string,
isArchive: boolean,
) => {
const chain = searchText
? _.chain(events).filter(e => isEventMatch(e, searchText))
: _.chain(events);
return chain
.filter(e => e.archived === isArchive)
.sortBy(e => parseTimestamp(e.timestamp))
.reverse()
.value();
};
const isEventMatch = (event: TimelineEvent, searchText: string) => {
return (
event.name.toLowerCase().includes(searchText) ||
event.description?.toLowerCase()?.includes(searchText)
);
};
const getMenuItems = (timeline: Timeline, collection: Collection) => {
return [
{
......
......@@ -21,7 +21,7 @@ const collectionProps = {
};
const mapStateToProps = () => ({
archived: true,
isArchive: true,
});
const mapDispatchToProps = (dispatch: any) => ({
......
......@@ -20,10 +20,6 @@ const collectionProps = {
Urls.extractCollectionId(props.params.slug),
};
const mapStateToProps = () => ({
archived: false,
});
const mapDispatchToProps = (dispatch: any) => ({
onArchive: async (event: TimelineEvent) => {
await dispatch(TimelineEvents.actions.setArchived(event, true));
......@@ -33,5 +29,5 @@ const mapDispatchToProps = (dispatch: any) => ({
export default _.compose(
Timelines.load(timelineProps),
Collections.load(collectionProps),
connect(mapStateToProps, mapDispatchToProps),
connect(null, mapDispatchToProps),
)(TimelineDetailsModal);
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment