-
-
Notifications
You must be signed in to change notification settings - Fork 2.6k
Fix state restore bug in FindAndReplaceDialogFragment #19981
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 | ||||
|---|---|---|---|---|---|---|
|
|
@@ -17,15 +17,17 @@ | |||||
| package com.ichi2.anki.browser | ||||||
|
|
||||||
| import android.app.Dialog | ||||||
| import android.content.Context | ||||||
| import android.os.Bundle | ||||||
| import android.widget.AdapterView | ||||||
| import android.widget.ArrayAdapter | ||||||
| import androidx.annotation.VisibleForTesting | ||||||
| import androidx.annotation.VisibleForTesting.Companion.PRIVATE | ||||||
| import androidx.appcompat.app.AlertDialog | ||||||
| import androidx.core.os.BundleCompat | ||||||
| import androidx.core.os.bundleOf | ||||||
| import androidx.core.text.HtmlCompat | ||||||
| import androidx.core.view.isVisible | ||||||
| import androidx.fragment.app.activityViewModels | ||||||
| import androidx.fragment.app.setFragmentResult | ||||||
| import androidx.lifecycle.lifecycleScope | ||||||
| import com.ichi2.anki.CardBrowser | ||||||
|
|
@@ -41,6 +43,7 @@ import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_REPLACE | |||||
| import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_SEARCH | ||||||
| import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.REQUEST_FIND_AND_REPLACE | ||||||
| import com.ichi2.anki.databinding.FragmentFindReplaceBinding | ||||||
| import com.ichi2.anki.libanki.NoteId | ||||||
| import com.ichi2.anki.notetype.ManageNotetypes | ||||||
| import com.ichi2.anki.ui.internationalization.toSentenceCase | ||||||
| import com.ichi2.anki.utils.ext.setFragmentResultListener | ||||||
|
|
@@ -66,7 +69,6 @@ import timber.log.Timber | |||||
| */ | ||||||
| // TODO desktop offers history for inputs | ||||||
| class FindAndReplaceDialogFragment : AnalyticsDialogFragment() { | ||||||
| private val browserViewModel by activityViewModels<CardBrowserViewModel>() | ||||||
| private lateinit var binding: FragmentFindReplaceBinding | ||||||
|
|
||||||
| override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { | ||||||
|
|
@@ -107,7 +109,15 @@ class FindAndReplaceDialogFragment : AnalyticsDialogFragment() { | |||||
| (dialog as? AlertDialog)?.positiveButton?.isEnabled = false | ||||||
| binding.contentViewsGroup.isVisible = false | ||||||
| binding.loadingViewsGroup.isVisible = true | ||||||
| val noteIds = browserViewModel.queryAllSelectedNoteIds() | ||||||
| val idsFile = | ||||||
| requireNotNull( | ||||||
| BundleCompat.getParcelable( | ||||||
| requireArguments(), | ||||||
| ARG_IDS, | ||||||
| IdsFile::class.java, | ||||||
| ), | ||||||
| ) | ||||||
| val noteIds = idsFile.getIds() | ||||||
| binding.onlySelectedNotesCheckBox.isChecked = noteIds.isNotEmpty() | ||||||
| binding.onlySelectedNotesCheckBox.isEnabled = noteIds.isNotEmpty() | ||||||
| val fieldsNames = | ||||||
|
|
@@ -134,7 +144,7 @@ class FindAndReplaceDialogFragment : AnalyticsDialogFragment() { | |||||
| } | ||||||
|
|
||||||
| // https://github.com/ankitects/anki/blob/64ca90934bc26ddf7125913abc9dd9de8cb30c2b/qt/aqt/browser/find_and_replace.py#L118 | ||||||
| @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) | ||||||
| @VisibleForTesting(otherwise = PRIVATE) | ||||||
| fun startFindReplace() { | ||||||
| val search = binding.inputSearch.text | ||||||
| val replacement = binding.inputReplace.text | ||||||
|
|
@@ -167,6 +177,7 @@ class FindAndReplaceDialogFragment : AnalyticsDialogFragment() { | |||||
| companion object { | ||||||
| const val TAG = "FindAndReplaceDialogFragment" | ||||||
| const val REQUEST_FIND_AND_REPLACE = "request_find_and_replace" | ||||||
| const val ARG_IDS = " arg_ids" | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| const val ARG_SEARCH = "arg_search" | ||||||
| const val ARG_REPLACEMENT = "arg_replacement" | ||||||
| const val ARG_FIELD = "arg_field" | ||||||
|
|
@@ -185,6 +196,16 @@ class FindAndReplaceDialogFragment : AnalyticsDialogFragment() { | |||||
| * the user selected "Tags" as the field target for the find and replace action. | ||||||
| */ | ||||||
| const val TAGS_AS_FIELD = "find_and_replace_dialog_fragment_tags_as_field" | ||||||
|
|
||||||
| fun newInstance( | ||||||
| context: Context, | ||||||
| noteIds: List<NoteId>, | ||||||
| ): FindAndReplaceDialogFragment { | ||||||
| val file = IdsFile(context.cacheDir, noteIds) | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We have a common issue that Is it feasible to handle this in the PR for this fragment?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please provide a name to the IdsFile, this will make it easier to clean up our 'bad' IdsFile implementation at a future date |
||||||
| return FindAndReplaceDialogFragment().apply { | ||||||
| arguments = bundleOf(ARG_IDS to file) | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,172 @@ | ||
| /* | ||
| * Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.com> | ||
| * | ||
| * This program is free software; you can redistribute it and/or modify it under | ||
| * the terms of the GNU General Public License as published by the Free Software | ||
| * Foundation; either version 3 of the License, or (at your option) any later | ||
| * version. | ||
| * | ||
| * This program is distributed in the hope that it will be useful, but WITHOUT ANY | ||
| * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A | ||
| * PARTICULAR PURPOSE. See the GNU General Public License for more details. | ||
| * | ||
| * You should have received a copy of the GNU General Public License along with | ||
| * this program. If not, see <http://www.gnu.org/licenses/>. | ||
| */ | ||
|
|
||
| package com.ichi2.anki.browser | ||
|
|
||
| import android.content.Context | ||
| import android.widget.Spinner | ||
| import android.widget.SpinnerAdapter | ||
| import androidx.core.os.bundleOf | ||
| import androidx.fragment.app.testing.FragmentScenario | ||
| import androidx.test.espresso.Espresso.onView | ||
| import androidx.test.espresso.assertion.ViewAssertions.matches | ||
| import androidx.test.espresso.matcher.RootMatchers.isDialog | ||
| import androidx.test.espresso.matcher.ViewMatchers.isChecked | ||
| import androidx.test.espresso.matcher.ViewMatchers.isEnabled | ||
| import androidx.test.espresso.matcher.ViewMatchers.isNotChecked | ||
| import androidx.test.espresso.matcher.ViewMatchers.isNotEnabled | ||
| import androidx.test.espresso.matcher.ViewMatchers.withId | ||
| import androidx.test.ext.junit.runners.AndroidJUnit4 | ||
| import com.ichi2.anki.CollectionManager.TR | ||
| import com.ichi2.anki.R | ||
| import com.ichi2.anki.RobolectricTest | ||
| import com.ichi2.anki.libanki.Note | ||
| import com.ichi2.anki.libanki.NoteId | ||
| import com.ichi2.anki.ui.internationalization.toSentenceCase | ||
| import kotlinx.coroutines.test.advanceUntilIdle | ||
| import org.hamcrest.CoreMatchers.equalTo | ||
| import org.hamcrest.MatcherAssert.assertThat | ||
| import org.junit.Test | ||
| import org.junit.runner.RunWith | ||
|
|
||
| @RunWith(AndroidJUnit4::class) | ||
| class FindAndReplaceDialogFragmentTest : RobolectricTest() { | ||
| @Test | ||
| fun `with no selected notes 'only selected notes' check box is not actionable`() = | ||
| runTest { | ||
| onFindReplaceFragment(emptyList()) { | ||
| // nothing selected so checkbox 'Only selected notes' should be unchecked and not enabled | ||
| onView(withId(R.id.only_selected_notes_check_box)) | ||
| .inRoot(isDialog()) | ||
| .check(matches(isNotEnabled())) | ||
| onView(withId(R.id.only_selected_notes_check_box)) | ||
| .inRoot(isDialog()) | ||
| .check(matches(isNotChecked())) | ||
| onView(withId(R.id.ignore_case_check_box)) | ||
| .inRoot(isDialog()) | ||
| .check(matches(isChecked())) | ||
| // using input as regex is not checked at start | ||
| onView(withId(R.id.input_as_regex_check_box)) | ||
| .inRoot(isDialog()) | ||
| .check(matches(isNotChecked())) | ||
| // as nothing is selected the target selector has only the two default options | ||
| assertThat(adapter.items, equalTo(targetContext.getDefaultTargets())) | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| fun `shows expected find-replace targets for selected notes`() = | ||
| runTest { | ||
| val n1 = createFindReplaceTestNote("A", "Car", "Lion") | ||
| val n2 = createFindReplaceTestNote("B", "Train", "Chicken") | ||
| createFindReplaceTestNote("C", "Plane", "Vulture") | ||
| onFindReplaceFragment(listOf(n1.id, n2.id)) { | ||
| // 2 default field options + 4 fields(2 from each of the two notes selected above) | ||
| // see createFindReplaceTestNote for test field names | ||
| val allTargets = | ||
| targetContext.getDefaultTargets() + | ||
| listOf("Afield0", "Afield1", "Bfield0", "Bfield1") | ||
| assertThat(adapter.items, equalTo(allTargets)) | ||
| } | ||
| } | ||
|
|
||
| // see #19929 | ||
| @Test | ||
| fun `'selected notes only' status is correct after fragment restore`() = | ||
| runTest { | ||
| val note = createFindReplaceTestNote("A", "kart", "kilogram") | ||
| val file = IdsFile(targetContext.cacheDir, listOf(note.id)) | ||
| val scenario = | ||
| FragmentScenario.launch( | ||
| fragmentClass = FindAndReplaceDialogFragment::class.java, | ||
| fragmentArgs = bundleOf(FindAndReplaceDialogFragment.ARG_IDS to file), | ||
| themeResId = R.style.Theme_Light, | ||
| ) | ||
| scenario.onFragment { fragment -> | ||
| advanceUntilIdle() | ||
| onView(withId(R.id.only_selected_notes_check_box)) | ||
| .inRoot(isDialog()) | ||
| .check(matches(isEnabled())) | ||
| onView(withId(R.id.only_selected_notes_check_box)) | ||
| .inRoot(isDialog()) | ||
| .check(matches(isChecked())) | ||
| // 2 default field options + 2 fields from the only note selected | ||
| val allTargets = | ||
| targetContext.getDefaultTargets() + listOf("Afield0", "Afield1") | ||
| assertThat(fragment.adapter.items, equalTo(allTargets)) | ||
| } | ||
| scenario.recreate() | ||
| scenario.onFragment { fragment -> | ||
| advanceUntilIdle() | ||
| onView(withId(R.id.only_selected_notes_check_box)) | ||
| .inRoot(isDialog()) | ||
| .check(matches(isEnabled())) | ||
| onView(withId(R.id.only_selected_notes_check_box)) | ||
| .inRoot(isDialog()) | ||
| .check(matches(isChecked())) | ||
| // check that the target list from before the recreate call wasn't reset | ||
| val allTargets = targetContext.getDefaultTargets() + listOf("Afield0", "Afield1") | ||
| assertThat(fragment.adapter.items, equalTo(allTargets)) | ||
| } | ||
| } | ||
|
|
||
| private fun onFindReplaceFragment( | ||
| noteIds: List<NoteId>, | ||
| action: FindAndReplaceDialogFragment.() -> Unit, | ||
| ) { | ||
| val file = IdsFile(targetContext.cacheDir, noteIds) | ||
| FragmentScenario | ||
| .launch( | ||
| fragmentClass = FindAndReplaceDialogFragment::class.java, | ||
| fragmentArgs = bundleOf(FindAndReplaceDialogFragment.ARG_IDS to file), | ||
| themeResId = R.style.Theme_Light, | ||
| ).onFragment { fragment -> fragment.action() } | ||
| } | ||
|
|
||
| /** | ||
| * 3 notetypes available(named A, B and C) each with two fields. | ||
| * Fields names follow the pattern: "${NotetypeName}field${0/1}" (ex: "Afield1"). | ||
| * "C" notetype has the same name for field 1 as notetype B! | ||
| * second is a [Pair] representing the field data(the note has only two fields) | ||
| */ | ||
| private fun createFindReplaceTestNote( | ||
| notetypeName: String, | ||
| field0: String, | ||
| field1: String, | ||
| ): Note { | ||
| addStandardNoteType("A", arrayOf("Afield0", "Afield1"), "", "") | ||
| addStandardNoteType("B", arrayOf("Bfield0", "Bfield1"), "", "") | ||
| addStandardNoteType("C", arrayOf("Cfield0", "Bfield1"), "", "") | ||
| return addNoteUsingNoteTypeName(notetypeName, field0, field1) | ||
| } | ||
|
|
||
| val FindAndReplaceDialogFragment.adapter: SpinnerAdapter | ||
| get() = dialog!!.findViewById<Spinner>(R.id.fields_selector).adapter as SpinnerAdapter | ||
|
|
||
| private val SpinnerAdapter.items: List<String> | ||
| get() = | ||
| mutableListOf<String>().apply { | ||
| for (position in 0 until count) { | ||
| add(getItem(position) as String) | ||
| } | ||
| } | ||
|
|
||
| private fun Context.getDefaultTargets() = | ||
| listOf( | ||
| TR.browsingAllFields().toSentenceCase(this, R.string.sentence_all_fields), | ||
| TR.editingTags(), | ||
| ) | ||
| } |
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.
Add handling for the file being deleted