| name | search-object |
| description | This skill should be used when extending search with a new object type. Guides through adding new searchable objects to the GraphQL search field including backend, tests, schema generation, and UI updates. |
Search Object Extension
Overview
Extend the GraphQL search functionality to include a new object type. This skill covers the complete workflow from backend implementation through UI integration.
When to Use This Skill
Use when the user requests:
- "Add [object] to search"
- "Make [object] searchable"
- "Extend search with [object]"
- "Allow users to search for [object]"
Complete Workflow
When adding a new object type to search, follow these steps in order:
Step 1: Extend Backend Search Implementation
All search code lives in sleuth/apps/search/.
1.1 Add Search Result Type
In sleuth/apps/search/gql_fields.py:31-37, add your new type to the SearchResultType enum:
class SearchResultType(graphene.Enum):
REVIEW = "review"
SURVEY = "survey"
DASHBOARD = "dashboard"
WORKSPACE = "workspace"
SPEC = "spec"
SESSION = "session"
YOUR_NEW_TYPE = "your_new_type"
1.2 Implement QuerySet Generator Function
Create _generate_<objects>_qs() function following this pattern:
def _generate_<objects>_qs(
org: Organization,
result_type: SearchResultType | None,
workspace_id: int | None,
term: str | None,
) -> QuerySet[YourModel]:
if result_type and result_type != SearchResultType.YOUR_NEW_TYPE:
return YourModel.objects.none()
objects_qs = YourModel.objects.filter(
org=org,
**dict(workspace_id=workspace_id) if workspace_id else {}
)
if term:
objects_qs = objects_qs.filter(name__icontains=term)
objects_qs = objects_qs.distinct()
return objects_qs
Key points:
- Always filter by
org first
- Support optional
workspace_id filtering
- Support optional
term filtering (usually against name/title field, but could be different depending on source model)
- Return
.none() if filtering for different result type
- Call
.distinct() before returning
See gql_fields.py:260-276 for SpecDB example.
1.3 Implement QuerySet Annotator Function
Create _annotate_<objects>_qs() function to normalize fields:
def _annotate_<objects>_qs(
objects_qs: QuerySet[YourModel],
fields: list[str]
) -> QuerySet[YourModel, dict[str, Any]]:
marker_subquery = WorkspaceMarker.objects.filter(
workspace=OuterRef("your_workspace_id_field")
)
annotated_objects = objects_qs.annotate(
type=Value(SearchResultType.YOUR_NEW_TYPE.value, output_field=CharField()),
workspace_marker_type=Subquery(marker_subquery.values("type")),
workspace_marker_name=Subquery(marker_subquery.values("name")),
workspace_marker_color=Subquery(marker_subquery.values("color")),
workspace_raw_id=F("your_workspace_id_field"),
workspace_name=F("your_workspace_name_field"),
chain_count=Value(0, output_field=IntegerField()),
last_viewed_on=F("your_timestamp_field"),
name=F("your_name_field"),
hash=F("your_hash_field"),
).values(*fields)
return annotated_objects
Required annotations (must match the fields list):
type: SearchResultType value for this object
workspace_marker_type/name/color: Workspace marker info via subquery
workspace_raw_id: The raw database ID of the workspace
workspace_name: Name of the workspace
chain_count: Number of items in chain (usually 0, only relevant for Reviews)
last_viewed_on: Timestamp for sorting results
name: Display name for the object
hash: Unique identifier (or empty string if using regular ID)
id: Database ID (usually already present)
org_id: Organization ID (usually already present)
See gql_fields.py:279-294 for SpecDB example.
1.4 Update Search Field Resolver
In the SearchField.resolve() method around gql_fields.py:154-160:
- Generate your queryset
- Annotate it
- Add to the union
your_objects_qs = _generate_<objects>_qs(org, result_type, workspace_db_id, term)
your_objects = _annotate_<objects>_qs(your_objects_qs, fields)
data = (
workspaces.union(reviews)
.union(specs)
.union(sessions)
.union(your_objects)
.order_by("-last_viewed_on", "name")
)
1.5 Update Result Processor (if needed)
If your object uses a special ID format (like hash instead of GID), update _process_search_results() around gql_fields.py:80-85:
elif item["type"] == SearchResultType.YOUR_NEW_TYPE.value:
artifact_gid = item["hash"]
artifact_name = item["name"]
Step 2: Add Test Coverage
Tests live in sleuth/apps/search/tests/test_search.py.
2.1 Create Test Factories (if needed)
If factories don't exist for your model, create them in your app's tests/factories.py following factoryboy patterns. See sleuth/apps/issues/tests/factories.py for examples.
2.2 Update Test Data Generator
In _generate_test_data() around test_search.py:101-115:
your_object_1 = YourModelFactory(
name="Banana Thing",
org=org,
workspace=workspace_1
)
your_object_2 = YourModelFactory(
name="Strawberry Thing",
org=org,
workspace=workspace_2
)
return (
org, not_org_owner, workspace_1, workspace_2,
review_1, review_2, survey_1, survey_2, dashboard,
spec_1, spec_2, session_1, session_2,
your_object_1, your_object_2,
)
2.3 Update Test Assertions
Update test assertions to include your new objects in expected results:
- Main search test - Add to default results around test_search.py:156-168
- Term filtering test - Add to "Banana" filtered results around test_search.py:197-204
- Type filtering test - Add new subtest for your type around test_search.py:235-246
- All fields test - Add full object details around test_search.py:289-303
- Pagination test - Update total count around test_search.py:274
Example type filter subtest:
result = execute_gql_query(GQL_QUERY, org=org, variables={"resultType": "YOUR_NEW_TYPE"})
assert result == {
"data": {
"search": {
"nodes": [
{"workspaceName": workspace_2.name, "artifactName": "Strawberry Thing"},
{"workspaceName": workspace_1.name, "artifactName": "Banana Thing"},
]
}
}
}
2.4 Run Tests
uv run ./bin/test.sh --keep-db sleuth/apps/search/tests/test_search.py
Fix any failures before proceeding.
Step 3: Regenerate GraphQL Schema and Types
After backend changes are complete and tested:
make generate-gql-schema-and-types
This updates the GraphQL schema and TypeScript types for the frontend.
Step 4: Update Frontend UI
Frontend changes are needed in multiple locations to display and filter the new object type.
4.1 Add Icon Assets
Add SVG icons in two styles to frontend/assets/used-icons/:
duotone-solid/<icon-name>.svg - For larger displays
light/<icon-name>.svg - For smaller displays
See commit ed051dd24 for examples.
4.2 Update Search Filter Buttons
In frontend/components/navigation/Search.vue:104-115, add filter button:
{
label: 'Your Objects',
icon: 'light:your-icon-name',
cb: () => toggleFilter(SearchResultType.YourNewType),
active: searchType.value === SearchResultType.YourNewType,
},
4.3 Update Search Item Display
In frontend/components/navigation/SearchItem.vue:24-30, add icon mapping:
const icon = computed(
() =>
({
[SearchResultType.Review]: "light:file",
[SearchResultType.Survey]: "light:clipboard-check",
[SearchResultType.Dashboard]: "light:square-poll-vertical",
[SearchResultType.Spec]: "light:folder-gear",
[SearchResultType.Session]: "light:square-kanban",
[SearchResultType.YourNewType]: "light:your-icon-name",
[SearchResultType.Workspace]: null,
}[props.type])
);
4.4 Update Artifact Routing
In frontend/utils/artifact.ts, add two mappings:
Import IssuesArtifactType and extend it with a new enum value for the new object:
import { IssuesArtifactType } from "~/api";
Add route mapping around artifact.ts:104-111:
[YourArtifactType.YourType]: {
name: 'your-route-name' as const,
params: { workspaceId, yourIdParam: artifactId },
},
Add type conversion around artifact.ts:122-125:
[SearchResultType.YourNewType]: YourArtifactType.YourType,
Add icon mapping around artifact.ts:143-154:
[SearchResultType.YourNewType]: {
bigIcon: 'duotone-solid:your-icon-name',
smallIcon: 'regular:your-icon-name',
color: 'text-your-color-400',
},
Step 5: Validate Changes
5.1 Run Backend Tests
uv run pytest --reuse-db sleuth/apps/search/tests/test_search.py
5.2 Run Code Quality Checks
make check-types
make lint-py
5.3 Test Frontend
- Start dev server and test search functionality
- Verify new filter button appears
- Verify objects appear in search results
- Verify clicking results navigates correctly
Common Patterns
For Objects with Team/Workspace Relationship
If your object relates to workspace through a team:
objects_qs = YourModel.objects.filter(
org=org,
**dict(team__workspace=workspace_id) if workspace_id else {}
)
workspace_raw_id=F("team__workspace"),
workspace_name=F("team__workspace__name"),
See gql_fields.py:269 and gql_fields.py:287-288 for examples.
For Objects with Direct Workspace Relationship
If your object has direct workspace FK:
objects_qs = YourModel.objects.filter(
org=org,
**dict(workspace_id=workspace_id) if workspace_id else {}
)
workspace_raw_id=F("workspace_id"),
workspace_name=F("workspace__name"),
See gql_fields.py:175 and gql_fields.py:196-197 for examples.
For Objects with Last Viewed Tracking
If your object has LastViewed tracking (like Reviews):
def _generate_<objects>_qs(...):
if term:
objects_qs = objects_qs.filter(name__icontains=term)
else:
objects_qs = objects_qs.filter(
last_viewed__user=user,
last_viewed__isnull=False
)
def _annotate_<objects>_qs(...):
last_viewed_subquery = LastViewed.objects.filter(
your_model=OuterRef("id"),
user=user
)
annotated_objects = objects_qs.annotate(
last_viewed_on=Subquery(last_viewed_subquery.values("on")),
)
See gql_fields.py:229-232 and gql_fields.py:243 for examples.
For Objects without Last Viewed Tracking
Use the model's timestamp field:
last_viewed_on=F("meta_last_updated_on"),
See gql_fields.py:290 for example.
Troubleshooting
Test Failures
"Factory not found"
- Create factory in your app's
tests/factories.py
- See
references/factory_patterns.md for examples
"Annotation field mismatch"
- Ensure all fields in the
fields list are annotated
- Check field names match exactly
- Verify types match (CharField, IntegerField, etc.)
"Union query incompatible"
- All unioned querysets must have identical field names and types
- Check
.values(*fields) includes all required fields
Schema Generation Fails
make check-types
make lint-py
make generate-gql-schema-and-types
Common issues:
- Missing imports in
gql_fields.py
- Type mismatches in annotate functions
- Invalid GraphQL enum values
Frontend Routing Issues
Objects don't navigate correctly
- Verify route name exists in router config
- Check param names match route definition
- Ensure
artifactId format matches expected format (GID vs hash vs plain ID)
Icons don't appear
- Verify SVG files exist in
used-icons/ directories
- Check icon names match in all locations (Search.vue, SearchItem.vue, artifact.ts)
- Restart dev server after adding new SVG assets
Reference
See references/complete_example.md for a detailed walkthrough of adding SpecDB and SessionDB objects to search, including all code changes and test updates.