Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ At least Redmine version `3.0` or higher required. Recommended version `5.0` or
| Select the **default fixed version** when _creating new issues_ | `< 4.1.1` |
| Check permissions for admin users who are not members of a project | `< 4.0.0` |
| Display project-available time entry activities when _adding spent time entries_ | `< 3.4.0` |
| Extended search | `< 3.3.0` |
| Remote Redmine search | `< 3.3.0` |

_Tested with Google Chrome Version 130 and Firefox 132_

Expand Down
50 changes: 46 additions & 4 deletions src/api/redmine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,17 @@ export class RedmineApi {
.then((res) => res.data);
}

async searchOpenIssues(
async searchIssues(
query: string,
{
scope,
titlesOnly,
openIssuesOnly,
}: {
scope?: "all" | "my_projects" | "bookmarks"; // "bookmarks" available since Redmine 5.1.0
titlesOnly?: boolean;
openIssuesOnly?: boolean;
},
{ offset, limit }: { offset: number; limit: number } = { offset: 0, limit: 100 }
): Promise<
TPaginatedResponse<{
Expand All @@ -81,11 +90,44 @@ export class RedmineApi {
return this.instance
.get(
`/search.json?${qs.stringify({
issues: 1,
q: query,
scope: "my_project",
titles_only: 1,
scope,
...(titlesOnly ? { titles_only: 1 } : {}),
...(openIssuesOnly ? { open_issues: 1 } : {}),
offset,
limit,
})}`
)
.then((res) => res.data); // available since Redmine 3.3.0
}

async searchIssuesInProject(
projectId: number,
query: string,
{
titlesOnly,
openIssuesOnly,
scope,
}: {
scope?: "subprojects";
titlesOnly?: boolean;
openIssuesOnly?: boolean;
},
{ offset, limit }: { offset: number; limit: number } = { offset: 0, limit: 100 }
): Promise<
TPaginatedResponse<{
results: TSearchResult[];
}>
> {
return this.instance
.get(
`/projects/${projectId}/search.json?${qs.stringify({
issues: 1,
open_issues: 1,
q: query,
scope,
...(titlesOnly ? { titles_only: 1 } : {}),
...(openIssuesOnly ? { open_issues: 1 } : {}),
offset,
limit,
})}`
Expand Down
11 changes: 8 additions & 3 deletions src/components/form/ToggleField.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { ComponentProps, useId } from "react";
import { useFieldContext } from "../../hooks/useAppForm";
import { Field, FieldContent, FieldError, FieldLabel } from "../ui/field";
import { Field, FieldContent, FieldDescription, FieldError, FieldInfo, FieldLabel } from "../ui/field";
import { Switch } from "../ui/switch";

type ToggleFieldProps = Omit<ComponentProps<typeof Switch>, "id" | "checked" | "onCheckedChange" | "onBlur">;
type ToggleFieldProps = Omit<ComponentProps<typeof Switch>, "id" | "checked" | "onCheckedChange" | "onBlur"> & {
description?: string;
info?: string;
};

export const ToggleField = ({ title, className, ...props }: ToggleFieldProps) => {
export const ToggleField = ({ title, description, info, className, ...props }: ToggleFieldProps) => {
const { state, handleChange, handleBlur } = useFieldContext<boolean>();
const isInvalid = !state.meta.isValid && state.meta.isTouched;
const id = useId();
Expand All @@ -17,9 +20,11 @@ export const ToggleField = ({ title, className, ...props }: ToggleFieldProps) =>
<span className="flex items-center gap-2">
<FieldLabel required={props.required} htmlFor={id}>
{title}
{info && <FieldInfo>{info}</FieldInfo>}
</FieldLabel>
{isInvalid && <FieldError variant="tooltip" errors={state.meta.errors} />}
</span>
{description && <FieldDescription>{description}</FieldDescription>}
</FieldContent>
</Field>
);
Expand Down
162 changes: 103 additions & 59 deletions src/components/issue/Filter.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/* eslint-disable react/no-children-prop */
import { useAppForm } from "@/hooks/useAppForm";
import { TIssue } from "@/types/redmine";
import { SlidersHorizontalIcon } from "lucide-react";
import { ReactNode, useState } from "react";
import { createContext, PropsWithChildren, use, useState } from "react";
import { useIntl } from "react-intl";
import { z } from "zod/v4";
import useMyProjects from "../../hooks/useMyProjects";
Expand All @@ -10,90 +11,133 @@ import { Button } from "../ui/button";
import { Form, FormGrid } from "../ui/form";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";

export type FilterQuery = {
projects: number[];
hideCompletedIssues: boolean;
const filterSettingsSchema = z.object({
projects: z.array(z.number()),
hideCompletedIssues: z.boolean(),
});

type FilterSettings = z.infer<typeof filterSettingsSchema>;

const defaultSettings: FilterSettings = { projects: [], hideCompletedIssues: false };

type FilterContextType = {
isLoading: boolean;
settings: FilterSettings;
setSettings: (settings: FilterSettings) => void;
};

const defaultFilter: FilterQuery = { projects: [], hideCompletedIssues: false };
const FilterContext = createContext<FilterContextType | undefined>(undefined);

type PropTypes = {
children: (state: { filter: FilterQuery; isLoading: boolean }) => ReactNode;
const FilterProvider = ({ children }: PropsWithChildren) => {
const { isLoading, data: settings, setData: setSettings } = useStorage<FilterSettings>("filter", defaultSettings);

return (
<FilterContext
value={{
isLoading,
settings,
setSettings,
}}
>
{children}
</FilterContext>
);
};

export const useFilter = () => {
const context = use(FilterContext);
if (!context) {
throw new Error("useFilter must be used within a FilterProvider component");
}
return context;
};

const Filter = ({ children }: PropTypes) => {
const FilterButton = () => {
const { formatMessage } = useIntl();
const filter = useFilter();

const [showFilter, setShowFilter] = useState(false);

const { data: projects, isLoading: isLoadingProjects } = useMyProjects({
enabled: showFilter,
});

const { data: filter, setData: setFilter, isLoading } = useStorage<FilterQuery>("filter", defaultFilter);

const form = useAppForm({
defaultValues: filter,
defaultValues: filter.settings,
validators: {
onChange: z.object({
projects: z.array(z.number()),
hideCompletedIssues: z.boolean(),
}),
onChange: filterSettingsSchema,
},
listeners: {
onChange: ({ formApi }) => {
if (formApi.state.isValid) {
setFilter(formApi.state.values);
formApi.handleSubmit();
}
},
},
onSubmit: ({ value }) => {
filter.setSettings(value);
},
});

return (
<>
<Popover open={showFilter} onOpenChange={setShowFilter}>
<div className="flex justify-end">
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="mb-1" tabIndex={-1}>
<SlidersHorizontalIcon />
{formatMessage({ id: "issues.filter" })}
</Button>
</PopoverTrigger>
</div>
<PopoverContent className="bg-background w-[18.5rem]">
<Form onSubmit={form.handleSubmit}>
<FormGrid className="gap-3">
<form.AppField
name="projects"
children={(field) => (
<field.ComboboxField
title={formatMessage({ id: "issues.filter.projects" })}
placeholder={formatMessage({ id: "issues.filter.projects" })}
noOptionsMessage={formatMessage({ id: "issues.filter.projects.no-options" })}
options={projects.map((project) => ({ value: project.id, label: project.name }))}
isLoading={isLoadingProjects}
mode="multiple"
/>
)}
/>

<form.AppField
name="hideCompletedIssues"
children={(field) => (
<field.CheckboxField
title={formatMessage({ id: "issues.filter.hide-completed-issues.title" })}
description={formatMessage({ id: "issues.filter.hide-completed-issues.description" })}
className="border-input dark:bg-input/30 rounded-lg border bg-transparent p-1.5"
/>
)}
/>
</FormGrid>
</Form>
</PopoverContent>
</Popover>
{children({ filter, isLoading })}
</>
<Popover open={showFilter} onOpenChange={setShowFilter}>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" tabIndex={-1}>
<SlidersHorizontalIcon />
{formatMessage({ id: "issues.filter" })}
</Button>
</PopoverTrigger>
<PopoverContent collisionPadding={10} className="bg-background w-[18.5rem]">
<Form onSubmit={form.handleSubmit}>
<FormGrid className="gap-3">
<form.AppField
name="projects"
children={(field) => (
<field.ComboboxField
title={formatMessage({ id: "issues.filter.projects" })}
placeholder={formatMessage({ id: "issues.filter.projects" })}
noOptionsMessage={formatMessage({ id: "issues.filter.projects.no-options" })}
options={projects.map((project) => ({ value: project.id, label: project.name }))}
isLoading={isLoadingProjects}
mode="multiple"
/>
)}
/>

<form.AppField
name="hideCompletedIssues"
children={(field) => (
<field.CheckboxField
title={formatMessage({ id: "issues.filter.hide-completed-issues.title" })}
description={formatMessage({ id: "issues.filter.hide-completed-issues.description" })}
className="border-input dark:bg-input/30 rounded-lg border bg-transparent p-1.5"
/>
)}
/>
</FormGrid>
</Form>
</PopoverContent>
</Popover>
);
};

export const filterIssues = (issues: TIssue[], settings: FilterSettings) => {
// projects
if (settings.projects.length > 0) {
issues = issues.filter((issue) => settings.projects.includes(issue.project.id));
}

// hide completed issues (done_ratio = 100%)
if (settings.hideCompletedIssues) {
issues = issues.filter((issue) => issue.done_ratio !== 100);
}

return issues;
};

const Filter = {
Provider: FilterProvider,
Button: FilterButton,
};

export default Filter;
Loading