Show question prompts one at a time
* Show question prompts one at a time * Make question prompt submit action literal
This commit is contained in:
@@ -9,7 +9,7 @@ import {
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Check, CircleHelp, X } from "lucide-react-native";
|
||||
import { Check, X } from "lucide-react-native";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -82,9 +82,18 @@ function QuestionOptionRow({
|
||||
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
|
||||
|
||||
return (
|
||||
<Pressable style={pressableStyle} onPress={handlePress} disabled={isResponding}>
|
||||
<Pressable
|
||||
style={pressableStyle}
|
||||
onPress={handlePress}
|
||||
disabled={isResponding}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={option.label}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
<View style={styles.optionItemContent}>
|
||||
<View style={styles.optionTextBlock}>
|
||||
<Text style={optionLabelStyle}>{option.label}</Text>
|
||||
@@ -102,8 +111,73 @@ function QuestionOptionRow({
|
||||
);
|
||||
}
|
||||
|
||||
interface QuestionNavButtonProps {
|
||||
index: number;
|
||||
total: number;
|
||||
isActive: boolean;
|
||||
isResponding: boolean;
|
||||
onSelect: (index: number) => void;
|
||||
}
|
||||
|
||||
function QuestionNavButton({
|
||||
index,
|
||||
total,
|
||||
isActive,
|
||||
isResponding,
|
||||
onSelect,
|
||||
}: QuestionNavButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const accessibilityState = useMemo(() => ({ selected: isActive }), [isActive]);
|
||||
const handlePress = useCallback(() => {
|
||||
onSelect(index);
|
||||
}, [index, onSelect]);
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
return [
|
||||
styles.questionNavButton,
|
||||
{
|
||||
backgroundColor:
|
||||
isActive || Boolean(hovered) ? theme.colors.surface2 : theme.colors.surface1,
|
||||
borderColor: isActive ? theme.colors.foregroundMuted : theme.colors.border,
|
||||
},
|
||||
pressed && styles.optionItemPressed,
|
||||
];
|
||||
},
|
||||
[
|
||||
isActive,
|
||||
theme.colors.border,
|
||||
theme.colors.foregroundMuted,
|
||||
theme.colors.surface1,
|
||||
theme.colors.surface2,
|
||||
],
|
||||
);
|
||||
const textStyle = useMemo(
|
||||
() => [
|
||||
styles.questionNavText,
|
||||
{ color: isActive ? theme.colors.foreground : theme.colors.foregroundMuted },
|
||||
],
|
||||
[isActive, theme.colors.foreground, theme.colors.foregroundMuted],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Question ${index + 1} of ${total}`}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-selected={isActive}
|
||||
testID={`question-form-question-nav-${index + 1}`}
|
||||
style={pressableStyle}
|
||||
onPress={handlePress}
|
||||
disabled={isResponding}
|
||||
>
|
||||
<Text style={textStyle}>{index + 1}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
interface QuestionOtherInputProps {
|
||||
qIndex: number;
|
||||
accessibilityLabel: string;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
isResponding: boolean;
|
||||
@@ -113,6 +187,7 @@ interface QuestionOtherInputProps {
|
||||
|
||||
function QuestionOtherInput({
|
||||
qIndex,
|
||||
accessibilityLabel,
|
||||
value,
|
||||
placeholder,
|
||||
isResponding,
|
||||
@@ -149,6 +224,7 @@ function QuestionOtherInput({
|
||||
<TextInput
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={otherInputStyle}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
value={value}
|
||||
@@ -163,15 +239,19 @@ function QuestionOtherInput({
|
||||
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const questions = parseQuestionFormQuestions(permission.request.input);
|
||||
const questions = useMemo(
|
||||
() => parseQuestionFormQuestions(permission.request.input),
|
||||
[permission.request.input],
|
||||
);
|
||||
|
||||
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
|
||||
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
|
||||
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
|
||||
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
|
||||
|
||||
const toggleOption = useCallback((qIndex: number, optIndex: number, multiSelect: boolean) => {
|
||||
setSelections((prev) => {
|
||||
const current = prev[qIndex] ?? new Set<number>();
|
||||
const toggleOption = useCallback(
|
||||
(qIndex: number, optIndex: number, multiSelect: boolean) => {
|
||||
const current = selections[qIndex] ?? new Set<number>();
|
||||
const next = new Set(current);
|
||||
if (multiSelect) {
|
||||
if (next.has(optIndex)) {
|
||||
@@ -179,23 +259,27 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
} else {
|
||||
next.add(optIndex);
|
||||
}
|
||||
} else if (next.has(optIndex)) {
|
||||
next.clear();
|
||||
} else {
|
||||
if (next.has(optIndex)) {
|
||||
next.clear();
|
||||
} else {
|
||||
next.clear();
|
||||
next.add(optIndex);
|
||||
}
|
||||
next.clear();
|
||||
next.add(optIndex);
|
||||
}
|
||||
return { ...prev, [qIndex]: next };
|
||||
});
|
||||
setOtherTexts((prev) => {
|
||||
if (!prev[qIndex]) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[qIndex];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
setSelections((prev) => ({ ...prev, [qIndex]: next }));
|
||||
setOtherTexts((prev) => {
|
||||
if (!prev[qIndex]) return prev;
|
||||
const nextTexts = { ...prev };
|
||||
delete nextTexts[qIndex];
|
||||
return nextTexts;
|
||||
});
|
||||
|
||||
if (!multiSelect && next.size > 0 && qIndex === activeQuestionIndex && questions) {
|
||||
setActiveQuestionIndex(Math.min(qIndex + 1, questions.length - 1));
|
||||
}
|
||||
},
|
||||
[activeQuestionIndex, questions, selections],
|
||||
);
|
||||
|
||||
const setOtherText = useCallback((qIndex: number, text: string) => {
|
||||
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
|
||||
@@ -208,6 +292,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
}, []);
|
||||
|
||||
const allAnswered = areQuestionsAnswered(questions, selections, otherTexts);
|
||||
const resolvedActiveQuestionIndex = questions
|
||||
? Math.min(activeQuestionIndex, questions.length - 1)
|
||||
: 0;
|
||||
const activeQuestion = questions?.[resolvedActiveQuestionIndex];
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!questions || !allAnswered || isResponding) return;
|
||||
@@ -248,6 +336,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
});
|
||||
}, [questions, onRespond, otherTexts, permission.request.input, selections]);
|
||||
|
||||
const handleSelectQuestion = useCallback((index: number) => {
|
||||
setActiveQuestionIndex(index);
|
||||
}, []);
|
||||
|
||||
const dismissButtonStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.actionButton,
|
||||
@@ -262,22 +354,16 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
|
||||
const submitDisabled = !allAnswered || isResponding;
|
||||
const submitButtonStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
({ pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.actionButton,
|
||||
{
|
||||
backgroundColor: hovered && !submitDisabled ? theme.colors.surface2 : theme.colors.surface1,
|
||||
borderColor: submitDisabled ? theme.colors.border : theme.colors.borderAccent,
|
||||
backgroundColor: theme.colors.accent,
|
||||
borderColor: theme.colors.accent,
|
||||
opacity: submitDisabled ? 0.5 : 1,
|
||||
},
|
||||
pressed && !submitDisabled ? styles.optionItemPressed : null,
|
||||
],
|
||||
[
|
||||
submitDisabled,
|
||||
theme.colors.surface2,
|
||||
theme.colors.surface1,
|
||||
theme.colors.border,
|
||||
theme.colors.borderAccent,
|
||||
],
|
||||
[submitDisabled, theme.colors.accent],
|
||||
);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
@@ -294,6 +380,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
() => [styles.questionText, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
);
|
||||
const questionNavStyle = useMemo(
|
||||
() => [styles.questionNav, isMobile && styles.questionNavMobile],
|
||||
[isMobile],
|
||||
);
|
||||
const actionsContainerStyle = useMemo(
|
||||
() => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop],
|
||||
[isMobile],
|
||||
@@ -302,9 +392,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
() => [styles.actionText, { color: theme.colors.foregroundMuted }],
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
const submitActionTextColor = allAnswered
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted;
|
||||
const submitActionTextColor = theme.colors.accentForeground;
|
||||
const submitActionTextStyle = useMemo(
|
||||
() => [styles.actionText, { color: submitActionTextColor }],
|
||||
[submitActionTextColor],
|
||||
@@ -315,52 +403,76 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
}
|
||||
|
||||
const dismissLabel = resolveDismissLabel(questions);
|
||||
const selected = selections[resolvedActiveQuestionIndex] ?? new Set<number>();
|
||||
const otherText = otherTexts[resolvedActiveQuestionIndex] ?? "";
|
||||
const showTextInput = activeQuestion ? questionShowsTextInput(activeQuestion) : false;
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
{questions.map((q, qIndex) => {
|
||||
const selected = selections[qIndex] ?? new Set<number>();
|
||||
const otherText = otherTexts[qIndex] ?? "";
|
||||
const showTextInput = questionShowsTextInput(q);
|
||||
|
||||
return (
|
||||
<View key={q.question} style={styles.questionBlock}>
|
||||
<View style={styles.questionHeader}>
|
||||
<Text style={questionTextStyle}>{q.question}</Text>
|
||||
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
{q.options.length > 0 ? (
|
||||
<View style={styles.optionsWrap}>
|
||||
{q.options.map((opt, optIndex) => (
|
||||
<QuestionOptionRow
|
||||
key={opt.label}
|
||||
qIndex={qIndex}
|
||||
optIndex={optIndex}
|
||||
option={opt}
|
||||
isSelected={selected.has(optIndex)}
|
||||
multiSelect={q.multiSelect}
|
||||
isResponding={isResponding}
|
||||
onToggle={toggleOption}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{showTextInput ? (
|
||||
<QuestionOtherInput
|
||||
qIndex={qIndex}
|
||||
value={otherText}
|
||||
placeholder={getQuestionInputPlaceholder(q)}
|
||||
<View style={containerStyle} testID="question-form-card">
|
||||
<View style={styles.questionTopRow}>
|
||||
<View style={styles.questionHeader}>
|
||||
<Text testID="question-form-current-question" style={questionTextStyle}>
|
||||
{activeQuestion?.question}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={questionNavStyle} testID="question-form-question-nav">
|
||||
{questions.map((question, qIndex) => {
|
||||
const isActive = qIndex === resolvedActiveQuestionIndex;
|
||||
return (
|
||||
<QuestionNavButton
|
||||
key={question.header}
|
||||
index={qIndex}
|
||||
total={questions.length}
|
||||
isActive={isActive}
|
||||
isResponding={isResponding}
|
||||
onChange={setOtherText}
|
||||
onSubmit={handleSubmit}
|
||||
onSelect={handleSelectQuestion}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{activeQuestion ? (
|
||||
<View key={activeQuestion.question} style={styles.questionBlock}>
|
||||
{activeQuestion.options.length > 0 ? (
|
||||
<View style={styles.optionsWrap}>
|
||||
{activeQuestion.options.map((opt, optIndex) => (
|
||||
<QuestionOptionRow
|
||||
key={opt.label}
|
||||
qIndex={resolvedActiveQuestionIndex}
|
||||
optIndex={optIndex}
|
||||
option={opt}
|
||||
isSelected={selected.has(optIndex)}
|
||||
multiSelect={activeQuestion.multiSelect}
|
||||
isResponding={isResponding}
|
||||
onToggle={toggleOption}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{showTextInput ? (
|
||||
<QuestionOtherInput
|
||||
qIndex={resolvedActiveQuestionIndex}
|
||||
accessibilityLabel={activeQuestion.question}
|
||||
value={otherText}
|
||||
placeholder={getQuestionInputPlaceholder(activeQuestion)}
|
||||
isResponding={isResponding}
|
||||
onChange={setOtherText}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={actionsContainerStyle}>
|
||||
<Pressable style={dismissButtonStyle} onPress={handleDeny} disabled={isResponding}>
|
||||
<Pressable
|
||||
style={dismissButtonStyle}
|
||||
onPress={handleDeny}
|
||||
disabled={isResponding}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={dismissLabel}
|
||||
testID="question-form-dismiss"
|
||||
>
|
||||
{respondingAction === "dismiss" ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
@@ -371,9 +483,16 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Pressable style={submitButtonStyle} onPress={handleSubmit} disabled={submitDisabled}>
|
||||
<Pressable
|
||||
style={submitButtonStyle}
|
||||
onPress={handleSubmit}
|
||||
disabled={submitDisabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Submit"
|
||||
testID="question-form-primary-action"
|
||||
>
|
||||
{respondingAction === "submit" ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||
<ActivityIndicator size="small" color={theme.colors.accentForeground} />
|
||||
) : (
|
||||
<View style={styles.actionContent}>
|
||||
<Check size={14} color={submitActionTextColor} />
|
||||
@@ -396,21 +515,50 @@ const styles = StyleSheet.create((theme) => ({
|
||||
questionBlock: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
questionTopRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
questionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[1],
|
||||
flex: 1,
|
||||
},
|
||||
questionText: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
lineHeight: 22,
|
||||
},
|
||||
optionsWrap: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
questionNav: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
questionNavMobile: {
|
||||
paddingRight: theme.spacing[1],
|
||||
},
|
||||
questionNavButton: {
|
||||
minWidth: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 999,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
},
|
||||
questionNavText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: "700",
|
||||
},
|
||||
optionItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
Reference in New Issue
Block a user