Skip to content
Merged
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
68 changes: 68 additions & 0 deletions components/batch/BatchActionBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import React from "react";
import { View, Text, Pressable } from "react-native";
import { Trash2, Edit3, X } from "lucide-react-native";
import { cn } from "~/lib/utils";

export interface BatchActionBarProps {
/** Number of selected records */
selectedCount: number;
/** Called when user taps batch-delete */
onBatchDelete?: () => void;
/** Called when user taps batch-edit (optional) */
onBatchEdit?: () => void;
/** Clear selection */
onClearSelection: () => void;
className?: string;
}

/**
* Action bar shown at the bottom when records are multi-selected.
*/
export function BatchActionBar({
selectedCount,
onBatchDelete,
onBatchEdit,
onClearSelection,
className,
}: BatchActionBarProps) {
if (selectedCount === 0) return null;

return (
<View
className={cn(
"flex-row items-center justify-between border-t border-border bg-card px-4 py-3",
className,
)}
>
<View className="flex-row items-center gap-2">
<Pressable onPress={onClearSelection} className="rounded-full bg-muted p-1.5">
<X size={14} color="#64748b" />
</Pressable>
<Text className="text-sm font-medium text-foreground">
{selectedCount} selected
</Text>
</View>

<View className="flex-row items-center gap-3">
{onBatchEdit && (
<Pressable
onPress={onBatchEdit}
className="flex-row items-center rounded-lg bg-primary/10 px-3 py-2"
>
<Edit3 size={14} color="#1e40af" />
<Text className="ml-1.5 text-xs font-semibold text-primary">Edit</Text>
</Pressable>
)}
{onBatchDelete && (
<Pressable
onPress={onBatchDelete}
className="flex-row items-center rounded-lg bg-destructive/10 px-3 py-2"
>
<Trash2 size={14} color="#ef4444" />
<Text className="ml-1.5 text-xs font-semibold text-destructive">Delete</Text>
</Pressable>
)}
</View>
</View>
);
}
72 changes: 72 additions & 0 deletions components/batch/BatchProgressIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React from "react";
import { View, Text } from "react-native";
import { cn } from "~/lib/utils";
import type { BatchProgress, BatchResult } from "~/hooks/useBatchOperations";

export interface BatchProgressIndicatorProps {
/** Current progress (while processing) */
progress: BatchProgress | null;
/** Final result (after completion) */
result: BatchResult | null;
className?: string;
}

/**
* Displays a progress bar and summary for batch operations.
*/
export function BatchProgressIndicator({
progress,
result,
className,
}: BatchProgressIndicatorProps) {
if (!progress && !result) return null;

// If we have a final result, show the summary
if (result && (!progress || progress.completed >= progress.total)) {
return (
<View className={cn("rounded-xl border border-border bg-card p-4", className)}>
<Text className="text-sm font-medium text-foreground">
Batch complete: {result.succeeded} succeeded, {result.failed} failed
</Text>
{result.errors.length > 0 && (
<View className="mt-2">
{result.errors.slice(0, 5).map((err, i) => (
<Text key={i} className="text-xs text-destructive">
• {err.recordId ? `Record ${err.recordId}: ` : ""}{err.message}
</Text>
))}
{result.errors.length > 5 && (
<Text className="mt-1 text-xs text-muted-foreground">
…and {result.errors.length - 5} more errors
</Text>
)}
</View>
)}
</View>
);
}

// In-progress state
if (!progress) return null;
const pct = progress.total > 0 ? (progress.completed / progress.total) * 100 : 0;

return (
<View className={cn("rounded-xl border border-border bg-card p-4", className)}>
<View className="mb-2 flex-row items-center justify-between">
<Text className="text-sm font-medium text-foreground">
Processing… {progress.completed}/{progress.total}
</Text>
{progress.failed > 0 && (
<Text className="text-xs text-destructive">{progress.failed} failed</Text>
)}
</View>
{/* Progress bar */}
<View className="h-2 rounded-full bg-muted">
<View
className="h-2 rounded-full bg-primary"
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</View>
</View>
);
}
4 changes: 4 additions & 0 deletions components/batch/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { BatchActionBar } from "./BatchActionBar";
export type { BatchActionBarProps } from "./BatchActionBar";
export { BatchProgressIndicator } from "./BatchProgressIndicator";
export type { BatchProgressIndicatorProps } from "./BatchProgressIndicator";
63 changes: 63 additions & 0 deletions components/common/OfflineIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React from "react";
import { View, Text, Pressable } from "react-native";
import { WifiOff, RefreshCw } from "lucide-react-native";
import { cn } from "~/lib/utils";

export interface OfflineIndicatorProps {
isOffline: boolean;
pendingCount?: number;
isSyncing?: boolean;
onSyncPress?: () => void;
className?: string;
}

/**
* Banner that appears when the device is offline.
* Shows pending mutation count and a manual sync button.
*/
export function OfflineIndicator({
isOffline,
pendingCount = 0,
isSyncing = false,
onSyncPress,
className,
}: OfflineIndicatorProps) {
if (!isOffline && pendingCount === 0) return null;

return (
<View
className={cn(
"flex-row items-center justify-between px-4 py-2",
isOffline ? "bg-amber-100 dark:bg-amber-900/30" : "bg-blue-50 dark:bg-blue-900/20",
className,
)}
>
<View className="flex-row items-center gap-2">
{isOffline && <WifiOff size={14} color="#d97706" />}
<Text className="text-xs font-medium text-foreground">
{isOffline
? "You are offline"
: `Syncing ${pendingCount} change${pendingCount !== 1 ? "s" : ""}…`}
</Text>
{pendingCount > 0 && isOffline && (
<Text className="text-xs text-muted-foreground">
• {pendingCount} pending
</Text>
)}
</View>

{!isOffline && pendingCount > 0 && onSyncPress && (
<Pressable
onPress={onSyncPress}
disabled={isSyncing}
className="flex-row items-center rounded-lg bg-primary/10 px-2.5 py-1"
>
<RefreshCw size={12} color="#1e40af" />
<Text className="ml-1 text-xs font-medium text-primary">
{isSyncing ? "Syncing…" : "Sync"}
</Text>
</Pressable>
)}
</View>
);
}
101 changes: 101 additions & 0 deletions components/query/FilterRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import React, { useMemo } from "react";
import { View, Text, TextInput, Pressable } from "react-native";
import { X } from "lucide-react-native";
import type { FieldDefinition } from "~/components/renderers/types";
import {
type SimpleFilter,
type FilterOperator,
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

FilterOperator is imported but never used in this file. Remove the unused import to satisfy linting.

Suggested change
type FilterOperator,

Copilot uses AI. Check for mistakes.
OPERATOR_META,
operatorsForFieldType,
} from "~/lib/query-builder";

/* ------------------------------------------------------------------ */
/* Props */
/* ------------------------------------------------------------------ */

export interface FilterRowProps {
filter: SimpleFilter;
fields: FieldDefinition[];
onUpdate: (patch: Partial<SimpleFilter>) => void;
onRemove: () => void;
}

/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */

export function FilterRow({ filter, fields, onUpdate, onRemove }: FilterRowProps) {
const selectedFieldDef = fields.find((f) => f.name === filter.field);
const fieldType = selectedFieldDef?.type ?? "text";

const availableOperators = useMemo(
() => operatorsForFieldType(fieldType),
[fieldType],
);

const operatorMeta = OPERATOR_META[filter.operator];
const needsValue = operatorMeta?.valueCount !== 0;
const needsSecondValue = operatorMeta?.valueCount === 2;

return (
<View className="mb-2 flex-row items-center gap-2">
{/* Field picker (simplified as a scrollable row of chips) */}
<Pressable
className="flex-1 rounded-lg border border-input bg-background px-3 py-2"
onPress={() => {
// Cycle through fields for simplicity
const currentIdx = fields.findIndex((f) => f.name === filter.field);
const nextField = fields[(currentIdx + 1) % fields.length];
if (nextField) {
onUpdate({ field: nextField.name });
Comment on lines +46 to +50
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

If fields is empty, (currentIdx + 1) % fields.length evaluates to NaN, so nextField becomes undefined. Guard against fields.length === 0 before doing the modulo/cycle logic to avoid runtime issues when metadata hasn’t loaded yet.

Copilot uses AI. Check for mistakes.
}
}}
>
<Text className="text-xs text-foreground" numberOfLines={1}>
{selectedFieldDef?.label ?? (filter.field || "Field…")}
</Text>
</Pressable>

{/* Operator picker */}
<Pressable
className="rounded-lg border border-input bg-background px-2 py-2"
onPress={() => {
const currentIdx = availableOperators.indexOf(filter.operator);
const next = availableOperators[(currentIdx + 1) % availableOperators.length];
if (next) onUpdate({ operator: next });
}}
>
<Text className="text-xs text-muted-foreground" numberOfLines={1}>
{operatorMeta?.label ?? filter.operator}
</Text>
</Pressable>

{/* Value input */}
{needsValue && (
<TextInput
className="flex-1 rounded-lg border border-input bg-background px-3 py-2 text-xs text-foreground"
value={String(filter.value ?? "")}
onChangeText={(text) => onUpdate({ value: text })}
placeholder="Value"
placeholderTextColor="#94a3b8"
/>
)}

{/* Second value (between) */}
{needsSecondValue && (
<TextInput
className="flex-1 rounded-lg border border-input bg-background px-3 py-2 text-xs text-foreground"
value={String(filter.value2 ?? "")}
onChangeText={(text) => onUpdate({ value2: text } as Partial<SimpleFilter>)}
placeholder="To"
placeholderTextColor="#94a3b8"
/>
)}

{/* Remove */}
<Pressable onPress={onRemove} className="p-1">
<X size={16} color="#ef4444" />
</Pressable>
</View>
);
}
41 changes: 41 additions & 0 deletions components/query/GlobalSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React from "react";
import { View, TextInput } from "react-native";
import { Search } from "lucide-react-native";
import { cn } from "~/lib/utils";

export interface GlobalSearchProps {
value: string;
onChangeText: (text: string) => void;
placeholder?: string;
className?: string;
}

/**
* Global full-text search input.
* Designed to sit above list views for cross-field filtering.
*/
export function GlobalSearch({
value,
onChangeText,
placeholder = "Search across all fields…",
className,
}: GlobalSearchProps) {
return (
<View
className={cn(
"flex-row items-center gap-2 rounded-xl border border-primary/30 bg-primary/5 px-3",
className,
)}
>
<Search size={18} color="#1e40af" />
<TextInput
className="h-11 flex-1 text-sm text-foreground placeholder:text-muted-foreground"
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor="#94a3b8"
returnKeyType="search"
/>
</View>
);
}
Loading
Loading