Show Add Project search loading state (#1762)
* fix(app): show add project search loading state The Add Project picker now treats a typed value that is ahead of the debounced directory search as an active search, so empty results show a loading message instead of the initial prompt or a blank list. * fix(app): avoid overlapping project picker states
This commit is contained in:
@@ -73,6 +73,23 @@ async function waitForSidebarProjectListReady(page: Page): Promise<void> {
|
|||||||
.waitFor({ state: "visible", timeout: 60_000 });
|
.waitFor({ state: "visible", timeout: 60_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test.describe("Project picker search", () => {
|
||||||
|
test("shows a loading state after typing while directory suggestions are pending", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarProjectListReady(page);
|
||||||
|
await page.getByTestId("sidebar-add-project").click();
|
||||||
|
|
||||||
|
const input = page.getByPlaceholder("Type a directory path...");
|
||||||
|
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||||
|
await input.fill("paseo-loading-state-no-match");
|
||||||
|
|
||||||
|
await expect(page.getByText("Start typing a path", { exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByText("Searching...", { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Projects are parents in the sidebar. Archiving the last workspace leaves the
|
// Projects are parents in the sidebar. Archiving the last workspace leaves the
|
||||||
// project row in place with a ghost "+ New workspace" child row.
|
// project row in place with a ghost "+ New workspace" child row.
|
||||||
test.describe("Project with no workspaces persists", () => {
|
test.describe("Project with no workspaces persists", () => {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
View,
|
View,
|
||||||
type PressableStateCallbackType,
|
type PressableStateCallbackType,
|
||||||
|
type StyleProp,
|
||||||
|
type TextStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -68,6 +70,68 @@ function PathRow({ option, active, onSelect }: PathRowProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProjectPickerResultsProps {
|
||||||
|
options: ProjectPickerOption[];
|
||||||
|
activeIndex: number;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
openErrorMessage: string | null;
|
||||||
|
hasQuery: boolean;
|
||||||
|
isSearching: boolean;
|
||||||
|
emptyTextStyle: StyleProp<TextStyle>;
|
||||||
|
errorTextStyle: StyleProp<TextStyle>;
|
||||||
|
onSelect: (path: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectPickerResults({
|
||||||
|
options,
|
||||||
|
activeIndex,
|
||||||
|
isSubmitting,
|
||||||
|
openErrorMessage,
|
||||||
|
hasQuery,
|
||||||
|
isSearching,
|
||||||
|
emptyTextStyle,
|
||||||
|
errorTextStyle,
|
||||||
|
onSelect,
|
||||||
|
}: ProjectPickerResultsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const canShowResultState = !isSubmitting && !openErrorMessage;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={styles.results}
|
||||||
|
contentContainerStyle={styles.resultsContent}
|
||||||
|
keyboardShouldPersistTaps="always"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Text style={emptyTextStyle}>{t("projectPicker.opening")}</Text> : null}
|
||||||
|
{!isSubmitting && openErrorMessage ? (
|
||||||
|
<Text style={errorTextStyle}>{openErrorMessage}</Text>
|
||||||
|
) : null}
|
||||||
|
{canShowResultState && options.length === 0 && !hasQuery ? (
|
||||||
|
<Text style={emptyTextStyle}>{t("projectPicker.empty")}</Text>
|
||||||
|
) : null}
|
||||||
|
{canShowResultState && isSearching ? (
|
||||||
|
<Text style={emptyTextStyle}>{t("projectPicker.searching")}</Text>
|
||||||
|
) : null}
|
||||||
|
{canShowResultState && !isSearching && options.length === 0 && hasQuery ? (
|
||||||
|
<Text style={emptyTextStyle}>{t("common.empty.noOptionsMatchSearch")}</Text>
|
||||||
|
) : null}
|
||||||
|
{canShowResultState && options.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{options.map((option, index) => (
|
||||||
|
<PathRow
|
||||||
|
key={`${option.kind}:${option.path}`}
|
||||||
|
option={option}
|
||||||
|
active={index === activeIndex}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ProjectPickerModal() {
|
export function ProjectPickerModal() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -116,6 +180,11 @@ export function ProjectPickerModal() {
|
|||||||
}),
|
}),
|
||||||
[directorySuggestionsQuery.data, query, recommendedPaths],
|
[directorySuggestionsQuery.data, query, recommendedPaths],
|
||||||
);
|
);
|
||||||
|
const hasQuery = query.trim().length > 0;
|
||||||
|
const isSearching =
|
||||||
|
hasQuery &&
|
||||||
|
options.length === 0 &&
|
||||||
|
(query !== debouncedQuery || directorySuggestionsQuery.isFetching);
|
||||||
|
|
||||||
const openErrorMessage = useMemo(() => {
|
const openErrorMessage = useMemo(() => {
|
||||||
if (!openErrorReason) {
|
if (!openErrorReason) {
|
||||||
@@ -276,32 +345,17 @@ export function ProjectPickerModal() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
<ProjectPickerResults
|
||||||
style={styles.results}
|
options={options}
|
||||||
contentContainerStyle={styles.resultsContent}
|
activeIndex={activeIndex}
|
||||||
keyboardShouldPersistTaps="always"
|
isSubmitting={isSubmitting}
|
||||||
showsVerticalScrollIndicator={false}
|
openErrorMessage={openErrorMessage}
|
||||||
>
|
hasQuery={hasQuery}
|
||||||
{isSubmitting ? <Text style={emptyTextStyle}>{t("projectPicker.opening")}</Text> : null}
|
isSearching={isSearching}
|
||||||
{!isSubmitting && openErrorMessage ? (
|
emptyTextStyle={emptyTextStyle}
|
||||||
<Text style={errorTextStyle}>{openErrorMessage}</Text>
|
errorTextStyle={errorTextStyle}
|
||||||
) : null}
|
onSelect={handleSelectPath}
|
||||||
{!isSubmitting && options.length === 0 && !query.trim() ? (
|
/>
|
||||||
<Text style={emptyTextStyle}>{t("projectPicker.empty")}</Text>
|
|
||||||
) : null}
|
|
||||||
{!isSubmitting && !(options.length === 0 && !query.trim()) ? (
|
|
||||||
<>
|
|
||||||
{options.map((option, index) => (
|
|
||||||
<PathRow
|
|
||||||
key={`${option.kind}:${option.path}`}
|
|
||||||
option={option}
|
|
||||||
active={index === activeIndex}
|
|
||||||
onSelect={handleSelectPath}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -388,6 +388,7 @@ describe("translation resources", () => {
|
|||||||
it("includes picker, file pane, and tool detail keys for the Batch 4L migration", () => {
|
it("includes picker, file pane, and tool detail keys for the Batch 4L migration", () => {
|
||||||
expect(en.projectPicker.placeholder).toBe("Type a directory path...");
|
expect(en.projectPicker.placeholder).toBe("Type a directory path...");
|
||||||
expect(en.projectPicker.opening).toBe("Opening project...");
|
expect(en.projectPicker.opening).toBe("Opening project...");
|
||||||
|
expect(en.projectPicker.searching).toBe("Searching...");
|
||||||
expect(en.projectPicker.empty).toBe("Start typing a path");
|
expect(en.projectPicker.empty).toBe("Start typing a path");
|
||||||
expect(en.branchSwitcher.currentBranch).toBe(
|
expect(en.branchSwitcher.currentBranch).toBe(
|
||||||
"Current branch: {{branchName}}. Press to switch branch.",
|
"Current branch: {{branchName}}. Press to switch branch.",
|
||||||
|
|||||||
@@ -1054,6 +1054,7 @@ export const ar: TranslationResources = {
|
|||||||
projectPicker: {
|
projectPicker: {
|
||||||
placeholder: "اكتب مسار الدليل...",
|
placeholder: "اكتب مسار الدليل...",
|
||||||
opening: "افتتاح المشروع...",
|
opening: "افتتاح المشروع...",
|
||||||
|
searching: "جارٍ البحث...",
|
||||||
empty: "ابدأ بكتابة المسار",
|
empty: "ابدأ بكتابة المسار",
|
||||||
errors: {
|
errors: {
|
||||||
directory_not_found: "لم يتم العثور على الدليل.",
|
directory_not_found: "لم يتم العثور على الدليل.",
|
||||||
|
|||||||
@@ -1060,6 +1060,7 @@ export const en = {
|
|||||||
projectPicker: {
|
projectPicker: {
|
||||||
placeholder: "Type a directory path...",
|
placeholder: "Type a directory path...",
|
||||||
opening: "Opening project...",
|
opening: "Opening project...",
|
||||||
|
searching: "Searching...",
|
||||||
empty: "Start typing a path",
|
empty: "Start typing a path",
|
||||||
errors: {
|
errors: {
|
||||||
directory_not_found: "Directory not found.",
|
directory_not_found: "Directory not found.",
|
||||||
|
|||||||
@@ -1087,6 +1087,7 @@ export const es: TranslationResources = {
|
|||||||
projectPicker: {
|
projectPicker: {
|
||||||
placeholder: "Escriba una ruta de directorio...",
|
placeholder: "Escriba una ruta de directorio...",
|
||||||
opening: "Proyecto de apertura...",
|
opening: "Proyecto de apertura...",
|
||||||
|
searching: "Buscando...",
|
||||||
empty: "Comience a escribir una ruta",
|
empty: "Comience a escribir una ruta",
|
||||||
errors: {
|
errors: {
|
||||||
directory_not_found: "No se encontró el directorio.",
|
directory_not_found: "No se encontró el directorio.",
|
||||||
|
|||||||
@@ -1089,6 +1089,7 @@ export const fr: TranslationResources = {
|
|||||||
projectPicker: {
|
projectPicker: {
|
||||||
placeholder: "Tapez un chemin de répertoire...",
|
placeholder: "Tapez un chemin de répertoire...",
|
||||||
opening: "Projet d'ouverture...",
|
opening: "Projet d'ouverture...",
|
||||||
|
searching: "Recherche en cours...",
|
||||||
empty: "Commencez à taper un chemin",
|
empty: "Commencez à taper un chemin",
|
||||||
errors: {
|
errors: {
|
||||||
directory_not_found: "Répertoire introuvable.",
|
directory_not_found: "Répertoire introuvable.",
|
||||||
|
|||||||
@@ -1067,6 +1067,7 @@ export const ja: TranslationResources = {
|
|||||||
projectPicker: {
|
projectPicker: {
|
||||||
placeholder: "ディレクトリパスを入力...",
|
placeholder: "ディレクトリパスを入力...",
|
||||||
opening: "プロジェクトを開いています...",
|
opening: "プロジェクトを開いています...",
|
||||||
|
searching: "検索中...",
|
||||||
empty: "パスを入力してください",
|
empty: "パスを入力してください",
|
||||||
errors: {
|
errors: {
|
||||||
directory_not_found: "ディレクトリが見つかりません。",
|
directory_not_found: "ディレクトリが見つかりません。",
|
||||||
|
|||||||
@@ -1073,6 +1073,7 @@ export const ptBR: TranslationResources = {
|
|||||||
projectPicker: {
|
projectPicker: {
|
||||||
placeholder: "Digite um caminho de diretório...",
|
placeholder: "Digite um caminho de diretório...",
|
||||||
opening: "Abrindo projeto...",
|
opening: "Abrindo projeto...",
|
||||||
|
searching: "Buscando...",
|
||||||
empty: "Comece digitando um caminho",
|
empty: "Comece digitando um caminho",
|
||||||
errors: {
|
errors: {
|
||||||
directory_not_found: "Diretório não encontrado.",
|
directory_not_found: "Diretório não encontrado.",
|
||||||
|
|||||||
@@ -1077,6 +1077,7 @@ export const ru: TranslationResources = {
|
|||||||
projectPicker: {
|
projectPicker: {
|
||||||
placeholder: "Введите путь к каталогу...",
|
placeholder: "Введите путь к каталогу...",
|
||||||
opening: "Открытие проекта...",
|
opening: "Открытие проекта...",
|
||||||
|
searching: "Идет поиск...",
|
||||||
empty: "Начните вводить путь",
|
empty: "Начните вводить путь",
|
||||||
errors: {
|
errors: {
|
||||||
directory_not_found: "Каталог не найден.",
|
directory_not_found: "Каталог не найден.",
|
||||||
|
|||||||
@@ -1039,6 +1039,7 @@ export const zhCN: TranslationResources = {
|
|||||||
projectPicker: {
|
projectPicker: {
|
||||||
placeholder: "输入目录路径...",
|
placeholder: "输入目录路径...",
|
||||||
opening: "正在打开 project...",
|
opening: "正在打开 project...",
|
||||||
|
searching: "正在搜索...",
|
||||||
empty: "开始输入路径",
|
empty: "开始输入路径",
|
||||||
errors: {
|
errors: {
|
||||||
directory_not_found: "找不到目录。",
|
directory_not_found: "找不到目录。",
|
||||||
|
|||||||
Reference in New Issue
Block a user