-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Categories Editor — browse, search, and import .comapeocat category sets (closes #143) #147
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
Open
luandro
wants to merge
26
commits into
main
Choose a base branch
from
agent/comapeo-cloud-app/issue-143
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
befe322
feat(issue-143): add Valibot schemas and fixtures for .comapeocat cat…
67d6958
feat(issue-143): add usePresets and useCategories data layer hooks
fce7453
feat(issue-143): add Categories route, nav icon, screen shell, and i18n
922f894
feat(issue-143): add category card grid with grouped layout
665c016
fix(issue-143): address review findings — TS errors, unused import, s…
51e3ccc
fix(issue-143): resolve field labels from useFields data, not preset …
330242a
feat(issue-143): add category detail pane and field viewer
576b89c
feat(issue-143): add .comapeocat import with Dexie persistence
cf107f5
fix(issue-143): restore usePresets backward compat, add useApiPresets
73017a8
fix(issue-143): fix ESLint set-state-in-effect in useCategorySets
312003e
fix(issue-143): prettier formatting + TS strict null fix
017b8be
fix(issue-143): prettier formatting on remaining files
8cf8a39
fix(issue-143): extract i18n messages + add pt/es translations
28b93c1
fix(issue-143): address claude-qwen review — error handling, cleanup,…
e51bbe1
fix(issue-143): add no-project empty state + fix FieldViewer type names
8888825
fix(issue-143): handleFileChange error handling + i18n coverage
3d47386
fix(issue-143): update FieldViewer test fixtures to camelCase type names
9101e14
fix(issue-143): use docRefSchema for preset fieldRefs/iconRef
dafaf1a
test(issue-143): add regression tests for fieldRefs/iconRef without url
7fd65a9
fix(issue-143): address claude-qwen review HIGH findings
31ba168
test(issue-143): add screen success path + real diacritic tests
e0291f3
test(issue-143): add MEDIUM coverage gaps — CategoryCard interactions…
e04a189
fix(issue-143): useApiPresets must use project remoteId (base32), not…
8d86068
fix(issue-143): guard projects loading/error states, assert remoteId …
fe8fb3f
fix(issue-143): handle 304 Not Modified in handleResponse + no-store …
abca306
fix(issue-143): remove unsafe 304→{data:[]} fallback, add no-store to…
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
28 changes: 0 additions & 28 deletions
28
screenshots/screenshot/home-project-empty-mobile.png.argos.json
This file was deleted.
Oops, something went wrong.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 5 additions & 5 deletions
10
...screenshot/mobile-settings.png.argos.json → .../mobile-observation-detail.png.argos.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { useQuery } from '@tanstack/react-query'; | ||
|
|
||
| import { apiClient } from '@/lib/api-client'; | ||
|
|
||
| /** Fetches presets directly from the archive server API (wire format). | ||
| * Uses the project's remoteId (server projectPublicId, base32) — NOT the | ||
| * local DB id (hex). Required because the server route parameter is validated | ||
| * against a base32 regex pattern. */ | ||
| export function useApiPresets(projectRemoteId: string | null) { | ||
| return useQuery({ | ||
| queryKey: ['api-presets', projectRemoteId], | ||
| queryFn: () => apiClient.getPresets(projectRemoteId!), | ||
| enabled: projectRemoteId !== null, | ||
| select: (data) => data.data, | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| interface PresetInput { | ||
| docId: string; | ||
| name: string; | ||
| tags: Record<string, unknown>; | ||
| fieldRefs: Array<{ docId: string }>; | ||
| iconRef?: { docId: string }; | ||
| } | ||
|
|
||
| export interface Category { | ||
| docId: string; | ||
| label: string; | ||
| fieldRefs: Array<{ docId: string; label?: string }>; | ||
| color?: string; | ||
| iconRef?: { docId: string }; | ||
| } | ||
|
|
||
| export interface CategoryGroup { | ||
| type: string; | ||
| categories: Category[]; | ||
| } | ||
|
|
||
| const ACCENT_RE = /[\u0300-\u036f]/g; | ||
|
|
||
| function stripDiacritics(s: string): string { | ||
| return s.normalize('NFD').replace(ACCENT_RE, ''); | ||
| } | ||
|
|
||
| function normalizeSearch(s: string): string { | ||
| return stripDiacritics(s).toLowerCase(); | ||
| } | ||
|
|
||
| function resolveLocaleName( | ||
| tags: Record<string, unknown>, | ||
| locale: string, | ||
| sourceName: string, | ||
| ): string { | ||
| const localeKey = `name:${locale}`; | ||
| if (typeof tags[localeKey] === 'string') return tags[localeKey] as string; | ||
|
|
||
| if (locale !== 'en') { | ||
| const enKey = 'name:en'; | ||
| if (typeof tags[enKey] === 'string') return tags[enKey] as string; | ||
| } | ||
|
|
||
| return sourceName; | ||
| } | ||
|
|
||
| function matchesSearch( | ||
| preset: PresetInput, | ||
| searchNormalized: string, | ||
| locale: string, | ||
| fieldLabels?: Map<string, string>, | ||
| ): boolean { | ||
| const label = resolveLocaleName(preset.tags, locale, preset.name); | ||
| if (normalizeSearch(label).includes(searchNormalized)) return true; | ||
|
|
||
| for (const ref of preset.fieldRefs) { | ||
| const resolvedLabel = fieldLabels?.get(ref.docId); | ||
| if ( | ||
| resolvedLabel && | ||
| normalizeSearch(resolvedLabel).includes(searchNormalized) | ||
| ) { | ||
| return true; | ||
| } | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| export function normalizeCategories( | ||
| data: PresetInput[], | ||
| locale: string, | ||
| searchQuery: string, | ||
| fieldLabels?: Map<string, string>, | ||
| ): CategoryGroup[] { | ||
| if (data.length === 0) return []; | ||
|
|
||
| const searchNormalized = normalizeSearch(searchQuery); | ||
|
|
||
| const groups = new Map<string, Category[]>(); | ||
|
|
||
| for (const preset of data) { | ||
| if ( | ||
| searchNormalized && | ||
| !matchesSearch(preset, searchNormalized, locale, fieldLabels) | ||
| ) { | ||
| continue; | ||
| } | ||
|
|
||
| const rawType = preset.tags.type; | ||
| const type = | ||
| typeof rawType === 'string' && rawType.trim() !== '' ? rawType : ''; // sentinel — translated at render (CategoryGrid) | ||
|
|
||
| const label = resolveLocaleName(preset.tags, locale, preset.name); | ||
|
|
||
| if (!groups.has(type)) { | ||
| groups.set(type, []); | ||
| } | ||
| groups.get(type)!.push({ | ||
| docId: preset.docId, | ||
| label, | ||
| fieldRefs: preset.fieldRefs.map((ref) => ({ | ||
| docId: ref.docId, | ||
| label: fieldLabels?.get(ref.docId), | ||
| })), | ||
| color: | ||
| typeof preset.tags.color === 'string' ? preset.tags.color : undefined, | ||
| iconRef: | ||
| typeof preset.iconRef === 'object' && | ||
| preset.iconRef !== null && | ||
| 'docId' in preset.iconRef | ||
| ? { docId: (preset.iconRef as { docId: string }).docId } | ||
| : undefined, | ||
| }); | ||
| } | ||
|
|
||
| const result: CategoryGroup[] = []; | ||
| const sortedTypes = [...groups.keys()].sort(); | ||
|
|
||
| for (const type of sortedTypes) { | ||
| const categories = groups.get(type)!; | ||
| categories.sort((a, b) => a.label.localeCompare(b.label)); | ||
| result.push({ type, categories }); | ||
| } | ||
|
|
||
| return result; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import { useCallback, useEffect, useState } from 'react'; | ||
|
|
||
| import { type CategorySetRecord, categoriesDb } from '@/lib/categories-db'; | ||
|
|
||
| export interface CategorySetSummary { | ||
| setId: string; | ||
| name: string; | ||
| importedAt: string; | ||
| categoryCount: number; | ||
| } | ||
|
|
||
| function toSummary(record: CategorySetRecord): CategorySetSummary { | ||
| return { | ||
| setId: record.setId, | ||
| name: record.name, | ||
| importedAt: record.importedAt, | ||
| categoryCount: Object.keys(record.categories).length, | ||
| }; | ||
| } | ||
|
|
||
| export function useCategorySets() { | ||
| const [sets, setSets] = useState<CategorySetSummary[]>([]); | ||
| const [isLoading, setIsLoading] = useState(true); | ||
| const [error, setError] = useState<string | null>(null); | ||
|
|
||
| useEffect(() => { | ||
| let cancelled = false; | ||
| categoriesDb.categorySets | ||
| .toArray() | ||
| .then((records) => { | ||
| if (!cancelled) { | ||
| setSets(records.map(toSummary)); | ||
| setError(null); | ||
| } | ||
| }) | ||
| .catch((err: unknown) => { | ||
| if (!cancelled) { | ||
| setError(err instanceof Error ? err.message : 'Failed to load sets'); | ||
| } | ||
| }) | ||
| .finally(() => { | ||
| if (!cancelled) setIsLoading(false); | ||
| }); | ||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, []); | ||
|
|
||
| const refresh = useCallback(async () => { | ||
| setIsLoading(true); | ||
| setError(null); | ||
| try { | ||
| const records = await categoriesDb.categorySets.toArray(); | ||
| setSets(records.map(toSummary)); | ||
| } catch (err: unknown) { | ||
| setError(err instanceof Error ? err.message : 'Failed to refresh sets'); | ||
| } finally { | ||
| setIsLoading(false); | ||
| } | ||
| }, []); | ||
|
|
||
| return { sets, isLoading, error, refresh }; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.