Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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: 2 additions & 0 deletions src/renderer/__mocks__/state-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ const mockFilters: FilterSettingsState = {
filterUserTypes: [],
filterIncludeHandles: [],
filterExcludeHandles: [],
filterIncludeOrganizations: [],
filterExcludeOrganizations: [],
filterSubjectTypes: [],
filterStates: [],
filterReasons: [],
Expand Down
76 changes: 76 additions & 0 deletions src/renderer/components/filters/OrganizationFilter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { fireEvent, render, screen } from '@testing-library/react';

import { mockSettings } from '../../__mocks__/state-mocks';
import { AppContext } from '../../context/App';
import { OrganizationFilter } from './OrganizationFilter';

const mockUpdateFilter = jest.fn();

describe('components/filters/OrganizationFilter.tsx', () => {
beforeEach(() => {
mockUpdateFilter.mockReset();
});

it('should render itself & its children', () => {
const props = {
updateFilter: mockUpdateFilter,
settings: mockSettings,
};

render(
<AppContext.Provider value={props}>
<OrganizationFilter />
</AppContext.Provider>,
);

expect(screen.getByText('Organizations')).toBeInTheDocument();
expect(screen.getByText('Include:')).toBeInTheDocument();
expect(screen.getByText('Exclude:')).toBeInTheDocument();
});

it('should handle organization includes', () => {
const props = {
updateFilter: mockUpdateFilter,
settings: mockSettings,
};

render(
<AppContext.Provider value={props}>
<OrganizationFilter />
</AppContext.Provider>,
);

const includeInput = screen.getByTitle('Include organizations');
fireEvent.change(includeInput, { target: { value: 'microsoft' } });
fireEvent.blur(includeInput);

expect(mockUpdateFilter).toHaveBeenCalledWith(
'filterIncludeOrganizations',
'microsoft',
true,
);
});

it('should handle organization excludes', () => {
const props = {
updateFilter: mockUpdateFilter,
settings: mockSettings,
};

render(
<AppContext.Provider value={props}>
<OrganizationFilter />
</AppContext.Provider>,
);

const excludeInput = screen.getByTitle('Exclude organizations');
fireEvent.change(excludeInput, { target: { value: 'github' } });
fireEvent.blur(excludeInput);

expect(mockUpdateFilter).toHaveBeenCalledWith(
'filterExcludeOrganizations',
'github',
true,
);
});
});
208 changes: 208 additions & 0 deletions src/renderer/components/filters/OrganizationFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { type FC, useContext, useEffect, useState } from 'react';

import {
CheckCircleFillIcon,
NoEntryFillIcon,
OrganizationIcon,
} from '@primer/octicons-react';
import { Box, Stack, Text, TextInputWithTokens } from '@primer/react';

import { AppContext } from '../../context/App';
import { IconColor, type Organization } from '../../types';
import {
hasExcludeOrganizationFilters,
hasIncludeOrganizationFilters,
} from '../../utils/notifications/filters/organizations';
import { Tooltip } from '../fields/Tooltip';
import { Title } from '../primitives/Title';
import { RequiresDetailedNotificationWarning } from './RequiresDetailedNotificationsWarning';

type InputToken = {
id: number;
text: string;
};

const tokenEvents = ['Enter', 'Tab', ' ', ','];

export const OrganizationFilter: FC = () => {
const { updateFilter, settings } = useContext(AppContext);

// biome-ignore lint/correctness/useExhaustiveDependencies: we only want to run this effect on organization filter changes
useEffect(() => {
if (!hasIncludeOrganizationFilters(settings)) {
setIncludeOrganizations([]);
}

if (!hasExcludeOrganizationFilters(settings)) {
setExcludeOrganizations([]);
}
}, [
settings.filterIncludeOrganizations,
settings.filterExcludeOrganizations,
]);

const mapValuesToTokens = (values: string[]): InputToken[] => {
return values.map((value, index) => ({
id: index,
text: value,
}));
};

const [includeOrganizations, setIncludeOrganizations] = useState<
InputToken[]
>(mapValuesToTokens(settings.filterIncludeOrganizations));

const addIncludeOrganizationsToken = (
event:
| React.KeyboardEvent<HTMLInputElement>
| React.FocusEvent<HTMLInputElement>,
) => {
const value = (event.target as HTMLInputElement).value.trim();

if (
value.length > 0 &&
!includeOrganizations.some((v) => v.text === value)
) {
setIncludeOrganizations([
...includeOrganizations,
{ id: includeOrganizations.length, text: value },
]);
updateFilter('filterIncludeOrganizations', value as Organization, true);

(event.target as HTMLInputElement).value = '';
}
};

const removeIncludeOrganizationToken = (tokenId: string | number) => {
const value =
includeOrganizations.find((v) => v.id === tokenId)?.text || '';
updateFilter('filterIncludeOrganizations', value as Organization, false);

setIncludeOrganizations(
includeOrganizations.filter((v) => v.id !== tokenId),
);
};

const includeOrganizationsKeyDown = (
event: React.KeyboardEvent<HTMLInputElement>,
) => {
if (tokenEvents.includes(event.key)) {
addIncludeOrganizationsToken(event);
}
};

const [excludeOrganizations, setExcludeOrganizations] = useState<
InputToken[]
>(mapValuesToTokens(settings.filterExcludeOrganizations));

const addExcludeOrganizationsToken = (
event:
| React.KeyboardEvent<HTMLInputElement>
| React.FocusEvent<HTMLInputElement>,
) => {
const value = (event.target as HTMLInputElement).value.trim();

if (
value.length > 0 &&
!excludeOrganizations.some((v) => v.text === value)
) {
setExcludeOrganizations([
...excludeOrganizations,
{ id: excludeOrganizations.length, text: value },
]);
updateFilter('filterExcludeOrganizations', value as Organization, true);

(event.target as HTMLInputElement).value = '';
}
};

const removeExcludeOrganizationToken = (tokenId: string | number) => {
const value =
excludeOrganizations.find((v) => v.id === tokenId)?.text || '';
updateFilter('filterExcludeOrganizations', value as Organization, false);

setExcludeOrganizations(
excludeOrganizations.filter((v) => v.id !== tokenId),
);
};

const excludeOrganizationsKeyDown = (
event: React.KeyboardEvent<HTMLInputElement>,
) => {
if (tokenEvents.includes(event.key)) {
addExcludeOrganizationsToken(event);
}
};

return (
<fieldset id="filter-organizations">
<Stack direction="horizontal" gap="condensed" align="baseline">
<Title icon={OrganizationIcon}>Organizations</Title>
<Tooltip
name="tooltip-filter-organizations"
tooltip={
<Stack direction="vertical" gap="condensed">
<Text>Filter notifications by organization.</Text>
<RequiresDetailedNotificationWarning />
Comment thread
setchy marked this conversation as resolved.
Outdated
</Stack>
}
/>
</Stack>
<Stack direction="vertical" gap="condensed">
<Stack
direction="horizontal"
gap="condensed"
align="center"
className="text-sm"
>
<Box className="font-medium text-gitify-font w-28">
<Stack direction="horizontal" gap="condensed" align="center">
<CheckCircleFillIcon className={IconColor.GREEN} />
<Text>Include:</Text>
</Stack>
</Box>
<TextInputWithTokens
title="Include organizations"
tokens={includeOrganizations}
onTokenRemove={removeIncludeOrganizationToken}
onKeyDown={includeOrganizationsKeyDown}
onBlur={addIncludeOrganizationsToken}
size="small"
disabled={
!settings.detailedNotifications ||
hasExcludeOrganizationFilters(settings)
}
block
/>
</Stack>

<Stack
direction="horizontal"
gap="condensed"
align="center"
className="text-sm"
>
<Box className="font-medium text-gitify-font w-28">
<Stack direction="horizontal" gap="condensed" align="center">
<NoEntryFillIcon className={IconColor.RED} />
<Text>Exclude:</Text>
</Stack>
</Box>
<TextInputWithTokens
title="Exclude organizations"
tokens={excludeOrganizations}
onTokenRemove={removeExcludeOrganizationToken}
onKeyDown={excludeOrganizationsKeyDown}
onBlur={addExcludeOrganizationsToken}
size="small"
disabled={
!settings.detailedNotifications ||
hasIncludeOrganizationFilters(settings)
}
block
/>
</Stack>
</Stack>
</fieldset>
);
};
4 changes: 4 additions & 0 deletions src/renderer/context/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ export const defaultFilters: FilterSettingsState = {
filterUserTypes: [],
filterIncludeHandles: [],
filterExcludeHandles: [],
filterIncludeOrganizations: [],
filterExcludeOrganizations: [],
filterSubjectTypes: [],
filterStates: [],
filterReasons: [],
Expand Down Expand Up @@ -193,6 +195,8 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
settings.filterUserTypes,
settings.filterIncludeHandles,
settings.filterExcludeHandles,
settings.filterIncludeOrganizations,
settings.filterExcludeOrganizations,
settings.filterReasons,
]);

Expand Down
2 changes: 2 additions & 0 deletions src/renderer/routes/Filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type FC, useContext } from 'react';
import { FilterIcon, FilterRemoveIcon } from '@primer/octicons-react';
import { Button, Stack, Tooltip } from '@primer/react';

import { OrganizationFilter } from '../components/filters/OrganizationFilter';
import { ReasonFilter } from '../components/filters/ReasonFilter';
import { StateFilter } from '../components/filters/StateFilter';
import { SubjectTypeFilter } from '../components/filters/SubjectTypeFilter';
Expand All @@ -27,6 +28,7 @@ export const FiltersRoute: FC = () => {
<Stack direction="vertical" gap="spacious">
<UserTypeFilter />
<UserHandleFilter />
<OrganizationFilter />
<SubjectTypeFilter />
<StateFilter />
<ReasonFilter />
Expand Down
Loading
Loading