Skip to content
Draft
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
3 changes: 3 additions & 0 deletions src/js/ONYXKEYS.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,7 @@ export default {
},

MILESTONES: 'milestones',

/** Saved filter presets (saved searches) */
SAVED_SEARCHES: 'savedSearches',
};
10 changes: 10 additions & 0 deletions src/js/component/panel/PanelIssues.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@
return [];
}

// Apply title filter first (case-insensitive includes)
if (filters && filters.titleFilter && typeof filters.titleFilter === 'string') {
const query = filters.titleFilter.trim().toLowerCase();
if (query) {
preparedIssues = _.filter(preparedIssues, (item) => (

Check failure on line 77 in src/js/component/panel/PanelIssues.js

View workflow job for this annotation

GitHub Actions / lint

Unexpected parentheses around single function argument having a body with no curly braces
item.title && item.title.toLowerCase().indexOf(query) !== -1
));
}
}

// Apply filters
if (applyFilters && filters && !_.isEmpty(filters)) {
Comment on lines +73 to 84
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

titleFilter is applied even when applyFilters is false. applyFilters is documented as the switch to ignore dashboard filters for certain views, so this change makes those views still subject to filtering. Consider moving the title filter logic inside the existing if (applyFilters && filters ...) block (or otherwise explicitly tie it to applyFilters) so the "ignore filters" behavior remains consistent.

Suggested change
// Apply title filter first (case-insensitive includes)
if (filters && filters.titleFilter && typeof filters.titleFilter === 'string') {
const query = filters.titleFilter.trim().toLowerCase();
if (query) {
preparedIssues = _.filter(preparedIssues, (item) => (
item.title && item.title.toLowerCase().indexOf(query) !== -1
));
}
}
// Apply filters
if (applyFilters && filters && !_.isEmpty(filters)) {
// Apply filters (including optional title filter)
if (applyFilters && filters && !_.isEmpty(filters)) {
// Apply title filter first (case-insensitive includes)
if (filters.titleFilter && typeof filters.titleFilter === 'string') {
const query = filters.titleFilter.trim().toLowerCase();
if (query) {
preparedIssues = _.filter(preparedIssues, (item) => (
item.title && item.title.toLowerCase().indexOf(query) !== -1
));
}
}

Copilot uses AI. Check for mistakes.
preparedIssues = _.filter(preparedIssues, (item) => {
Expand Down
32 changes: 32 additions & 0 deletions src/js/lib/SavedSearchesStorage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import getBrowser from './browser';

const STORAGE_KEY = 'k2_saved_searches';

/**
* @returns {Promise<Array<{id: string, name: string, filters: Object, createdAt: number}>>}
*/
function getSavedSearches() {
const browser = getBrowser();
return new Promise((resolve) => {
browser.storage.local.get(STORAGE_KEY, (result) => {
const list = result && result[STORAGE_KEY];
Comment on lines +1 to +12
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

./browser default export is the browser/chrome object (see other imports like messenger.js), but this file imports it as getBrowser and calls it as a function. That will throw at runtime. Import the browser object directly (e.g. import ksBrowser from './browser') and remove the function call, or change browser.js to export a function (but that would require updating existing call sites).

Copilot uses AI. Check for mistakes.
resolve(Array.isArray(list) ? list : []);

Check failure on line 13 in src/js/lib/SavedSearchesStorage.js

View workflow job for this annotation

GitHub Actions / lint

Prefer '_.isArray' over the native function
});
});
}

/**
* @param {Array<{id: string, name: string, filters: Object, createdAt: number}>} list
* @returns {Promise<void>}
*/
function setSavedSearches(list) {
const browser = getBrowser();
return new Promise((resolve) => {
browser.storage.local.set({[STORAGE_KEY]: list}, resolve);
});
}

export {
getSavedSearches,
setSavedSearches,
};
100 changes: 100 additions & 0 deletions src/js/lib/actions/SavedSearches.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import ReactNativeOnyx from 'react-native-onyx';
import _ from 'underscore';
import ONYXKEYS from '../../ONYXKEYS';
import * as Issues from './Issues';
import {getSavedSearches as getSavedSearchesFromStorage, setSavedSearches} from '../SavedSearchesStorage';

Check failure on line 5 in src/js/lib/actions/SavedSearches.js

View workflow job for this annotation

GitHub Actions / lint

Do not import individual exports from local modules. Prefer 'import * as' syntax

/**
* Load saved searches from extension storage into Onyx for UI
* @returns {Promise<Array>}
*/
function getSavedSearches() {
return getSavedSearchesFromStorage().then((list) => {
ReactNativeOnyx.set(ONYXKEYS.SAVED_SEARCHES, list);
return list;
});
}

/**

Check failure on line 18 in src/js/lib/actions/SavedSearches.js

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc @returns for function
* Save the current filter state as a named saved search
* @param {string} name - Display name for the saved search
* @param {Object} filters - Current filter state (e.g. from ONYXKEYS.ISSUES.FILTER)
*/
function saveSavedSearch(name, filters) {
const trimmedName = (name || '').trim();
if (!trimmedName) {
return Promise.resolve();
}

const saved = {
id: `saved-search-${Date.now()}`,
name: trimmedName,
filters: _.clone(filters || {}),
createdAt: Date.now(),
};

return getSavedSearchesFromStorage().then((list) => {
const next = [...list, saved];
return setSavedSearches(next).then(() => {
ReactNativeOnyx.set(ONYXKEYS.SAVED_SEARCHES, next);
});
});
}

/**
* Apply a saved search by id (sets Onyx ISSUES.FILTER to the saved filters)
* @param {string} id
* @returns {Promise<void>} Resolves after filters have been applied (waits for saveFilters if it returns a promise)
*/
function applySavedSearch(id) {
return getSavedSearchesFromStorage().then((list) => {
const found = _.findWhere(list, {id});
if (!found || !found.filters) {
return Promise.resolve();
}
const result = Issues.saveFilters(found.filters);
return (result && typeof result.then === 'function') ? result : Promise.resolve();
});
}

/**

Check failure on line 60 in src/js/lib/actions/SavedSearches.js

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc @returns for function
* Remove a saved search by id
* @param {string} id
*/
function deleteSavedSearch(id) {
return getSavedSearchesFromStorage().then((list) => {
const next = _.reject(list, item => item.id === id);
return setSavedSearches(next).then(() => {
ReactNativeOnyx.set(ONYXKEYS.SAVED_SEARCHES, next);
});
});
}

/**

Check failure on line 73 in src/js/lib/actions/SavedSearches.js

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc @returns for function
* Rename a saved search
* @param {string} id
* @param {string} newName
*/
function renameSavedSearch(id, newName) {
const trimmed = (newName || '').trim();
if (!trimmed) {
return Promise.resolve();
}

return getSavedSearchesFromStorage().then((list) => {
const next = _.map(list, (item) => (

Check failure on line 85 in src/js/lib/actions/SavedSearches.js

View workflow job for this annotation

GitHub Actions / lint

Unexpected parentheses around single function argument having a body with no curly braces
item.id === id ? {...item, name: trimmed} : item
));
return setSavedSearches(next).then(() => {
ReactNativeOnyx.set(ONYXKEYS.SAVED_SEARCHES, next);
});
});
}

export {
getSavedSearches,
saveSavedSearch,
applySavedSearch,
deleteSavedSearch,
renameSavedSearch,
};
3 changes: 3 additions & 0 deletions src/js/lib/filterPropTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,7 @@ export default PropTypes.shape({

/** A milestone the issues should belong to */
milestone: PropTypes.string,

/** Optional title search to filter issues by title (case-insensitive includes) */
titleFilter: PropTypes.string,
});
113 changes: 112 additions & 1 deletion src/js/module/dashboard/Filters.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,17 @@
import {withOnyx} from 'react-native-onyx';
import * as Milestones from '../../lib/actions/Milestones';
import * as Issues from '../../lib/actions/Issues';
import * as SavedSearches from '../../lib/actions/SavedSearches';
import ONYXKEYS from '../../ONYXKEYS';
import filterPropTypes from '../../lib/filterPropTypes';

const savedSearchShape = PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
filters: PropTypes.object,

Check failure on line 15 in src/js/module/dashboard/Filters.js

View workflow job for this annotation

GitHub Actions / lint

Prop type "object" is forbidden
createdAt: PropTypes.number,
});

const propTypes = {
/** Data for milestones in GH */
milestones: PropTypes.objectOf(PropTypes.shape({
Expand All @@ -20,25 +28,36 @@

/** The filters to apply to the GH issues */
filters: filterPropTypes,

/** List of saved filter presets */
savedSearches: PropTypes.arrayOf(savedSearchShape),
};
const defaultProps = {
milestones: {},
filters: {},
savedSearches: [],
};

class Filters extends React.Component {
constructor(props) {
super(props);

this.saveFilters = this.saveFilters.bind(this);
this.saveCurrentSearch = this.saveCurrentSearch.bind(this);
this.applySavedSearch = this.applySavedSearch.bind(this);
this.renameSavedSearch = this.renameSavedSearch.bind(this);
this.deleteSavedSearch = this.deleteSavedSearch.bind(this);
}

componentDidMount() {
Milestones.get();
SavedSearches.getSavedSearches();
}

componentDidUpdate(prevProps) {
if (this.props.milestones === prevProps.milestones) {
const milestonesChanged = this.props.milestones !== prevProps.milestones;
const filtersChanged = !_.isEqual(this.props.filters, prevProps.filters);
if (!milestonesChanged && !filtersChanged) {
return;
}

Expand All @@ -50,6 +69,9 @@
this.fieldTask.checked = this.props.filters.task;
this.fieldFeature.checked = this.props.filters.feature;
$(this.fieldMilestone).val(this.props.filters.milestone);
if (this.fieldTitleFilter) {
this.fieldTitleFilter.value = this.props.filters.titleFilter || '';
}
}
Comment on lines 58 to 75
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The form inputs are uncontrolled and default to unchecked/empty, but componentDidUpdate bails out when filters is empty. With Filters now rendered on the dashboard, the UI will never reflect the default filter state (e.g. improvement/task/feature should likely start checked) until something writes to ONYXKEYS.ISSUES.FILTER. Consider initializing defaults on mount when filters are empty, or updating the fields with fallback defaults instead of returning early.

Copilot uses AI. Check for mistakes.

/**
Expand All @@ -57,7 +79,7 @@
*
* @param {SyntheticEvent} e
*/
saveFilters(e) {

Check failure on line 82 in src/js/module/dashboard/Filters.js

View workflow job for this annotation

GitHub Actions / lint

saveFilters should be placed after getCurrentFilters
e.preventDefault();

Issues.saveFilters({
Expand All @@ -65,9 +87,45 @@
task: this.fieldTask.checked,
feature: this.fieldFeature.checked,
milestone: this.fieldMilestone.value,
titleFilter: this.fieldTitleFilter ? this.fieldTitleFilter.value.trim() : '',
});
}

getCurrentFilters() {
return {
improvement: this.fieldImprovement ? this.fieldImprovement.checked : true,
task: this.fieldTask ? this.fieldTask.checked : true,
feature: this.fieldFeature ? this.fieldFeature.checked : true,
milestone: this.fieldMilestone ? this.fieldMilestone.value : '',
titleFilter: this.fieldTitleFilter ? this.fieldTitleFilter.value.trim() : '',
};
}

saveCurrentSearch(e) {
e.preventDefault();
const name = window.prompt('Name this saved search:');
if (name == null) return;

Check failure on line 107 in src/js/module/dashboard/Filters.js

View workflow job for this annotation

GitHub Actions / lint

Expected { after 'if' condition
const filters = this.getCurrentFilters();
SavedSearches.saveSavedSearch(name, filters);
}

applySavedSearch(id) {
SavedSearches.applySavedSearch(id);
}

renameSavedSearch(id) {
const saved = _.findWhere(this.props.savedSearches, {id});
if (!saved) return;
const newName = window.prompt('Rename saved search:', saved.name);
if (newName == null) return;
SavedSearches.renameSavedSearch(id, newName);
}

deleteSavedSearch(id) {
if (!window.confirm('Delete this saved search?')) return;
SavedSearches.deleteSavedSearch(id);
}

render() {
return (
<div className="mb-3">
Expand Down Expand Up @@ -106,8 +164,58 @@
</select>
</div>

<div className="checkbox">
<label htmlFor="titleFilter">
Search by title:&nbsp;
</label>
<input
id="titleFilter"
type="text"
name="titleFilter"
placeholder="Filter by issue title..."
ref={el => this.fieldTitleFilter = el}
defaultValue={this.props.filters && this.props.filters.titleFilter ? this.props.filters.titleFilter : ''}
/>
</div>

<button type="submit" className="btn btn-default">Apply</button>
<button type="button" className="btn btn-default ml-2" onClick={this.saveCurrentSearch}>
Save search
</button>
</form>
{_.size(this.props.savedSearches) > 0 && (
<div className="mt-2">
<strong>Saved searches:</strong>
<ul className="list-unstyled mt-1">
{_.map(this.props.savedSearches, saved => (
<li key={saved.id} className="mb-1 d-flex align-items-center gap-2">
<span className="flex-grow-1">{saved.name}</span>
<button
type="button"
className="btn btn-default btn-sm"
onClick={() => this.applySavedSearch(saved.id)}
>
Apply
</button>
<button
type="button"
className="btn btn-default btn-sm"
onClick={() => this.renameSavedSearch(saved.id)}
>
Rename
</button>
<button
type="button"
className="btn btn-default btn-sm"
onClick={() => this.deleteSavedSearch(saved.id)}
>
Delete
</button>
</li>
))}
</ul>
</div>
)}
</div>
);
}
Expand All @@ -123,4 +231,7 @@
filters: {
key: ONYXKEYS.ISSUES.FILTER,
},
savedSearches: {
key: ONYXKEYS.SAVED_SEARCHES,
},
})(Filters);
8 changes: 4 additions & 4 deletions src/js/module/dashboard/ListIssues.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';

// import Filters from './Filters';
import Filters from './Filters';
import ListIssuesAssigned from './ListIssuesAssigned';
import ListPRsAssigned from './ListPRsAssigned';
import ListPRsReviewing from './ListPRsReviewing';
Expand All @@ -22,16 +22,16 @@ function ListIssues(props) {

<ListPRsReviewing pollInterval={props.pollInterval * 2.5} />

<Filters />

<ListIssuesAssigned pollInterval={props.pollInterval} />

<ListPRsAssigned pollInterval={props.pollInterval * 2.5} />

<ListIssuesHotPicks pollInterval={props.pollInterval * 2.5} />

{/* Hide these for now while we focus on NewDot */}
{/* <Filters />

<ListIssuesEngineering pollInterval={props.pollInterval * 2.5} /> */}
{/* <ListIssuesEngineering pollInterval={props.pollInterval * 2.5} /> */}
</div>
);
}
Expand Down
Loading