-
Notifications
You must be signed in to change notification settings - Fork 12
Add saved searches feature #304
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
|
||
| resolve(Array.isArray(list) ? list : []); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * @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, | ||
| }; | ||
| 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'; | ||
|
|
||
| /** | ||
| * 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
|
||
| * 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
|
||
| * 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
|
||
| * 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) => ( | ||
| item.id === id ? {...item, name: trimmed} : item | ||
| )); | ||
| return setSavedSearches(next).then(() => { | ||
| ReactNativeOnyx.set(ONYXKEYS.SAVED_SEARCHES, next); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| export { | ||
| getSavedSearches, | ||
| saveSavedSearch, | ||
| applySavedSearch, | ||
| deleteSavedSearch, | ||
| renameSavedSearch, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
| createdAt: PropTypes.number, | ||
| }); | ||
|
|
||
| const propTypes = { | ||
| /** Data for milestones in GH */ | ||
| milestones: PropTypes.objectOf(PropTypes.shape({ | ||
|
|
@@ -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; | ||
| } | ||
|
|
||
|
|
@@ -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
|
||
|
|
||
| /** | ||
|
|
@@ -57,7 +79,7 @@ | |
| * | ||
| * @param {SyntheticEvent} e | ||
| */ | ||
| saveFilters(e) { | ||
| e.preventDefault(); | ||
|
|
||
| Issues.saveFilters({ | ||
|
|
@@ -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; | ||
| 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"> | ||
|
|
@@ -106,8 +164,58 @@ | |
| </select> | ||
| </div> | ||
|
|
||
| <div className="checkbox"> | ||
| <label htmlFor="titleFilter"> | ||
| Search by title: | ||
| </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> | ||
| ); | ||
| } | ||
|
|
@@ -123,4 +231,7 @@ | |
| filters: { | ||
| key: ONYXKEYS.ISSUES.FILTER, | ||
| }, | ||
| savedSearches: { | ||
| key: ONYXKEYS.SAVED_SEARCHES, | ||
| }, | ||
| })(Filters); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
titleFilteris applied even whenapplyFiltersis false.applyFiltersis 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 existingif (applyFilters && filters ...)block (or otherwise explicitly tie it toapplyFilters) so the "ignore filters" behavior remains consistent.