diff --git a/.react-doctor/false-positives.md b/.react-doctor/false-positives.md index ee32cf7..44088bc 100644 --- a/.react-doctor/false-positives.md +++ b/.react-doctor/false-positives.md @@ -20,6 +20,12 @@ storage access elsewhere. `ad-order-status` is a false positive while each select's ID matches its adjacent `FieldLabel.htmlFor`. The shared FieldLabel renders a native label. The advertising admin and order tests exercise these controls using their accessible labels. +Their role-based queries must also find the visible selects as `combobox` with +the names `Tier`, `Status` and `Status`. Each select must have exactly one native +label whose `control` resolves to that select. The tests check that the same +accessible names remain after changing the campaign tier/status or order filter, +and that the selected values update. This does not exempt custom labels that +fail to forward `htmlFor` or selects with missing or duplicate IDs. ## Member project form drafts @@ -64,3 +70,24 @@ with no browser reconciliation or row-local state. Commit-message lines likewise belong to an immutable commit keyed by commit identity. These predicates do not exempt editable, reorderable, filterable or stateful lists. The interactive catalogue pagination and GitHub statistics now use semantic keys. + +## Admin editor client-side submission + +`react-doctor/no-prevent-default` on the collection, create-project and +edit-project forms is intentional while they own the client-side submission +lifecycle. Collection submission flushes the BlockNote editor, validates the +current selection and content, sends the expected revision, and preserves edits +on conflict. Project submission flushes deferred Markdown before TanStack Form +validation and mutation; upload errors restore or clear temporary image keys +without losing the other fields. The edit form also uses explicit review actions +to choose the next status before submission. + +`admin-collection-editor.test.tsx`, `admin-project-review-form.test.tsx`, +`github-readme-import.test.tsx` and `github-metadata-prefill.test.tsx` exercise +validation, same-task editor flushing, explicit save, conflicts and retained +drafts. The canonical rule recipe specifically retains `onSubmit` with +`preventDefault` for deliberate client-side validation, uploads and SPA flows. +Moving the call to another handler or replacing it with a client-only action +would hide the diagnostic without adding progressive enhancement. Review again +if these forms gain a native/server-action submission contract. This note does +not exempt unrelated forms or anchor click handlers. diff --git a/apps/web/src/components/action-confirmation.tsx b/apps/web/src/components/action-confirmation.tsx index 88170b6..8f3e44b 100644 --- a/apps/web/src/components/action-confirmation.tsx +++ b/apps/web/src/components/action-confirmation.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react' +import type { ComponentProps, ReactNode } from 'react' import { Alert, AlertDescription } from '@altstack/ui/components/alert' import { @@ -24,6 +24,7 @@ export function ActionConfirmation({ disabled = false, confirmLabel = 'Confirm', cancelLabel = 'Cancel', + confirmVariant, }: { open: boolean title: string @@ -36,6 +37,7 @@ export function ActionConfirmation({ disabled?: boolean confirmLabel?: string cancelLabel?: string + confirmVariant?: ComponentProps['variant'] }) { return ( {cancelLabel} - + {pending ? 'Saving…' : confirmLabel} diff --git a/apps/web/src/components/table-column-headings.tsx b/apps/web/src/components/table-column-headings.tsx new file mode 100644 index 0000000..6eebe2e --- /dev/null +++ b/apps/web/src/components/table-column-headings.tsx @@ -0,0 +1,13 @@ +import { TableHead, TableHeader, TableRow } from '@altstack/ui/components/table' + +export function TableColumnHeadings({ headings }: { headings: Array }) { + return ( + + + {headings.map((heading) => ( + {heading} + ))} + + + ) +} diff --git a/apps/web/src/features/admin-blogs/editor.tsx b/apps/web/src/features/admin-blogs/editor.tsx index 00f242d..d65fd50 100644 --- a/apps/web/src/features/admin-blogs/editor.tsx +++ b/apps/web/src/features/admin-blogs/editor.tsx @@ -68,7 +68,7 @@ const empty: BlogBody = { status: 'draft', } -export function BlogEditor({ initial }: { initial?: Initial }) { +function useBlogEditor(initial: Initial | undefined) { const queryClient = useQueryClient() const navigate = useNavigate() const router = useRouter() @@ -120,6 +120,27 @@ export function BlogEditor({ initial }: { initial?: Initial }) { {fieldErrors[name]} ) : null } + function onTitleChange(title: string) { + setValue((previous) => { + return { + ...previous, + title, + ...(!customizedSlug.current + ? { + slug: slugify(title) + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+/, '') + .slice(0, 160) + .replace(/-+$/, ''), + } + : {}), + } + }) + } + function onSlugChange(slug: string) { + customizedSlug.current = true + setField('slug', slug) + } async function submit(event: React.SubmitEvent) { event.preventDefault() if (busy) return @@ -180,6 +201,52 @@ export function BlogEditor({ initial }: { initial?: Initial }) { }) await navigate({ to: '/admin/blogs' }) } + return { + initial, + queryClient, + router, + editor, + onTitleChange, + onSlugChange, + value, + fieldErrors, + error, + conflict, + reload, + setReload, + setReloading, + coverBusy, + setCoverBusy, + coverRevision, + saving, + busy, + blocker, + setField, + fieldProps, + fieldError, + submit, + } +} + +export function BlogEditor({ initial }: { initial?: Initial }) { + const state = useBlogEditor(initial) + const { + queryClient, + router, + editor, + value, + error, + conflict, + reload, + setReload, + setReloading, + coverBusy, + saving, + busy, + blocker, + submit, + } = state + return (
)} - - - Title - { - const title = event.target.value - setValue((previous) => { - return { - ...previous, - title, - ...(!customizedSlug.current - ? { - slug: slugify(title) - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+/, '') - .slice(0, 160) - .replace(/-+$/, ''), - } - : {}), - } - }) - }} - /> - {fieldError('title')} - - - Slug - { - customizedSlug.current = true - setField('slug', event.target.value) - }} - /> - - Public URL: /blogs/{value.slug || 'your-post-slug'}. Generated - from the title until you customize it. - - {fieldError('slug')} - - - Description -