Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
17 KiB
Follow-ups Batch (#38, #28, #41, #26) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use
- [ ].
Goal: Four small, well-specified follow-ups: enum-type SearchHitView.visibility (#38); carry the offending field in the set_fields 422 so the UI can highlight it (#28); normalize localized_text to the default language on save (#41); pin the pnpm version (#26).
Tech Stack: Rust (axum, utoipa), React + TS, react-hook-form, Vitest + RTL + MSW.
Conventions: nightly fmt; clippy -D warnings; no any/eslint-disable/@ts-ignore; en/sv parity; codename ban; bundle ≤150 KB gz. Test infra: DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev, MEILI_URL=http://localhost:7700, MEILI_MASTER_KEY=masterKey. cargo from repo root; web from web/.
Task 1: Backend — SearchHitView.visibility enum (#38) + set_fields field-level 422 (#28)
Files: Modify crates/api/src/admin_search.rs, crates/api/src/admin_objects.rs, crates/api/src/openapi.rs; Test crates/api/tests/admin_objects.rs; Regenerate web/src/api/schema.d.ts.
#38 — enum-type the search hit visibility
- Step 1: In
crates/api/src/admin_search.rs,SearchHitView.visibility(line ~31,pub visibility: String): add the attribute above it:
#[schema(value_type = domain::Visibility)]
pub visibility: String,
(domain::Visibility already derives ToSchema and is registered in openapi.rs from #29 — no further registration needed.)
#28 — carry the offending field in the 422
The db FieldError already names the field (UnknownField(String), TypeMismatch { field, .. }, Unresolved { field, .. }). Surface it.
- Step 2: In
crates/api/src/admin_objects.rs, add a response DTO near the other views:
/// Field-level rejection detail for `set_fields`, so the UI can highlight the field.
#[derive(serde::Serialize, utoipa::ToSchema)]
pub(crate) struct FieldErrorView {
/// The flexible-field key that was rejected.
pub field: String,
/// Machine code: "unknown" | "type_mismatch" | "unresolved".
pub code: String,
}
- Step 3: Change the
set_fieldshandler to return a body on the field-error 422s. Its signature is-> Result<StatusCode, StatusCode>; change to-> axum::response::Responseand build responses (importaxum::response::IntoResponse):
) -> axum::response::Response {
use axum::response::IntoResponse;
let Ok(object_id) = id.parse::<ObjectId>() else {
return StatusCode::NOT_FOUND.into_response();
};
let mut tx = match state.db.pool().begin().await {
Ok(tx) => tx,
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
};
let result =
db::catalog::set_object_fields(&mut tx, actor(&auth.user), object_id, &values).await;
match result {
Ok(()) => {
if tx.commit().await.is_err() {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
reindex(&state, object_id).await;
StatusCode::NO_CONTENT.into_response()
}
Err(db::catalog::FieldError::ObjectNotFound) => StatusCode::NOT_FOUND.into_response(),
Err(db::catalog::FieldError::Db(_)) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
Err(db::catalog::FieldError::UnknownField(field)) => (
StatusCode::UNPROCESSABLE_ENTITY,
Json(FieldErrorView { field, code: "unknown".to_owned() }),
)
.into_response(),
Err(db::catalog::FieldError::TypeMismatch { field, .. }) => (
StatusCode::UNPROCESSABLE_ENTITY,
Json(FieldErrorView { field, code: "type_mismatch".to_owned() }),
)
.into_response(),
Err(db::catalog::FieldError::Unresolved { field, .. }) => (
StatusCode::UNPROCESSABLE_ENTITY,
Json(FieldErrorView { field, code: "unresolved".to_owned() }),
)
.into_response(),
}
}
Update the #[utoipa::path(...)] on set_fields: the 422 response now has a body — change/add (status = 422, body = FieldErrorView, description = "A field was rejected") in its responses(...).
-
Step 4: Register
admin_objects::FieldErrorViewincrates/api/src/openapi.rscomponents(schemas(...)). -
Step 5: Test — add to
crates/api/tests/admin_objects.rs(reuse its harness: seed editor, login, create an object). Create an object, then PUT/api/admin/objects/{id}/fieldswith an unknown field key → assert422and the body{ field: "<that key>", code: "unknown" }. (Mirror an existing set-fields test if present; if a field-definition is needed for a type_mismatch case, theunknowncase needs none — simplest.) Read the file for the exact request/parse helpers. -
Step 6: Build + backend tests:
cargo +nightly fmt
cargo clippy --workspace --all-targets
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api
All green (existing set_fields tests still pass — success path still 204; the failure path now carries a body but the status is unchanged at 422).
- Step 7: Regenerate client:
cargo build -p server
lsof -ti :8080 | xargs kill 2>/dev/null
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey ./target/debug/server &
SERVER_PID=$!
sleep 2
( cd web && pnpm gen:api )
kill "$SERVER_PID"
grep -n "FieldErrorView" web/src/api/schema.d.ts
# confirm SearchHitView.visibility now references the Visibility union:
grep -n "SearchHitView" web/src/api/schema.d.ts
FieldErrorView present; SearchHitView.visibility → components["schemas"]["Visibility"]. cd web && pnpm typecheck clean. Diff additive.
- Step 8: Commit:
cd /Users/olsson/Laboratory/biggus-dickus
git add crates/api web/src/api/schema.d.ts
git commit -m "feat(api): field-level set_fields 422 body (#28); enum-type SearchHitView.visibility (#38)"
Task 2: Frontend — surface the rejected field & highlight it (#28)
Files: Modify web/src/api/queries.ts, web/src/objects/object-form.tsx, web/src/objects/object-new-page.tsx, web/src/objects/object-edit-form.tsx, web/src/i18n/{en,sv}.json; Test web/src/objects/object-form.test.tsx or the relevant existing object test.
-
Step 1: i18n — add
form.fieldRejectedto BOTHen.jsonandsv.json(interpolated):- en
form:"fieldRejected": "The field \"{{field}}\" was rejected — check its value" - sv
form:"fieldRejected": "Fältet \"{{field}}\" avvisades — kontrollera värdet"
- en
-
Step 2: A typed rejection in
useSetFields— inweb/src/api/queries.ts, add near the other errors:
export class FieldRejection extends Error {
constructor(public readonly field: string, public readonly code: string) {
super(`field rejected: ${field}`);
this.name = "FieldRejection";
}
}
Update useSetFields's mutationFn to parse the 422 body and throw FieldRejection:
mutationFn: async ({ id, fields }: { id: string; fields: Record<string, unknown> }) => {
const { response, error } = await api.PUT("/api/admin/objects/{id}/fields", {
params: { path: { id } },
body: fields as Record<string, never>,
});
if (response.status === 204) return;
if (response.status === 422 && error && typeof error === "object" && "field" in error) {
const detail = error as { field: string; code: string };
throw new FieldRejection(detail.field, detail.code);
}
throw new Error("set fields failed");
},
(openapi-fetch puts the 422 body in error because the operation declares a 422 body schema. If error typing is awkward, narrow defensively as above — no any.)
- Step 3: Thread a field-error into the form —
object-form.tsxowns the react-hook-form instance. Add an optional propfieldErrorKey?: string | nulland, viauseEffect, set/clear the RHF error so the field highlights:
// in the ObjectForm props type:
fieldErrorKey?: string | null;
// inside the component (form is the useForm instance; t available):
useEffect(() => {
if (fieldErrorKey) {
form.setError(`fields.${fieldErrorKey}` as never, {
type: "server",
message: t("form.fieldRejected", { field: fieldErrorKey }),
});
}
}, [fieldErrorKey, form, t]);
(The as never is to satisfy RHF's path typing for a dynamic flexible-field path; if a cleaner typed path is available without any, use it — as never is acceptable here and is NOT as any. Confirm lint accepts it; if react-hooks/exhaustive-deps complains, include the listed deps.)
-
Step 4: Parent catch sets the field key — in
object-new-page.tsxandobject-edit-form.tsx, thecatchcurrently doessetError(t("form.rejected")). Capture the rejected field too:- Add state
const [fieldErrorKey, setFieldErrorKey] = useState<string | null>(null); - In the catch:
if (e instanceof FieldRejection) { setFieldErrorKey(e.field); setError(t("form.fieldRejected", { field: e.field })); } else { setError(t("form.rejected")); }(importFieldRejectionfrom../api/queries). - Pass
fieldErrorKey={fieldErrorKey}to<ObjectForm>. - Clear
setFieldErrorKey(null)at the top ofonSubmit(alongsidesetError(null)). (Forobject-edit-form.tsx, which also reads alocation.state.fieldsErrorflag, keep that path but layer the new typed handling on top.)
- Add state
-
Step 5: Test — add a test (in the object form/new-page test file, MSW) where PUT
/api/admin/objects/:id/fieldsreturns422with{ field: "dimensions", code: "type_mismatch" }. Submit the form; assert the field-rejected message appears (/dimensions/i+ "rejected") and, if practical, that the field's input is marked invalid (aria-invalidor an error message near it). Use the existing object-form test setup; read it for the render/submit pattern. -
Step 6: Verify + commit:
cd web && pnpm test && pnpm typecheck && pnpm lint && pnpm build && pnpm check:size
cd /Users/olsson/Laboratory/biggus-dickus
git add web
git commit -m "feat(web): highlight the offending field on a set_fields 422 (#28)"
Task 3: Frontend — visibility-badge typing (#38) + localized_text normalize-on-save (#41)
Files: Modify web/src/objects/visibility-badge.tsx, web/src/objects/object-form.tsx; Test the object-form/field tests.
#38 — tighten the VisibilityBadge prop
- Step 1:
web/src/objects/visibility-badge.tsx— change the prop fromstringto the schema union (now that all callers pass it, incl. search hits after Task 1):
import type { components } from "../api/schema";
type Visibility = components["schemas"]["Visibility"];
export function VisibilityBadge({ visibility }: { visibility: Visibility }) {
const { t } = useTranslation();
return (
<Badge variant="outline" className={STYLES[visibility] ?? ""}>
{t(`visibility.${visibility}`)}
</Badge>
);
}
Run pnpm typecheck — every caller (object-list, object-detail, search-result-row) now passes the union (object/search hit visibility are the union post-#29/#38). Fix any caller that still has a widened string (there should be none).
#41 — normalize localized_text to the default language on save
The edit path seeds defaultValues.fields from object.fields verbatim, so a localized_text value authored under another language keeps that key. Normalize in pruneFields so only the default-language key is saved.
-
Step 2: In
web/src/objects/object-form.tsx:- Add
import { useConfig } from "../config/config-context";and inside the component:const { default_language } = useConfig();. - Compute the set of localized_text field keys from the loaded definitions:
const localizedTextKeys = new Set( (definitions ?? []).filter((d) => d.data_type === "localized_text").map((d) => d.key), ); - Pass both into
pruneFieldsat its call site (const fields = pruneFields(data.fields, localizedTextKeys, default_language);). - Update
pruneFieldsto accept them and, for a localized_text key, keep only the default-language sub-value:function pruneFields( fields: Record<string, unknown>, localizedTextKeys: Set<string>, defaultLang: string, ): Record<string, unknown> { const out: Record<string, unknown> = {}; for (const [key, value] of Object.entries(fields)) { if (value === undefined || value === null || value === "") continue; if (typeof value === "object" && !Array.isArray(value)) { const map = value as Record<string, unknown>; // Single-language authoring: a localized_text value keeps only the default lang. const entries = localizedTextKeys.has(key) ? Object.entries(map).filter(([lang]) => lang === defaultLang) : Object.entries(map); const inner = Object.fromEntries( entries.filter(([, v]) => v !== undefined && v !== null && v !== ""), ); if (Object.keys(inner).length > 0) out[key] = inner; continue; } out[key] = value; } return out; }
- Add
-
Step 3: Test — add/extend a test: an object whose
localized_textfield value is{ en: "Old", sv: "Ny" }, edited on ansv-default instance, submitsfieldscontaining only{ <key>: { sv: "Ny" } }(theenkey stripped). Use the object-form test harness (thedefinitionsfixture has alocalized_textfield —title_ml). Assert the pruned payload via the submit handler / the PUT body. -
Step 4: Verify + commit:
cd web && pnpm test && pnpm typecheck && pnpm lint && pnpm build && pnpm check:size
cd /Users/olsson/Laboratory/biggus-dickus
git add web
git commit -m "fix(web): VisibilityBadge typed to the union (#38); normalize localized_text to default language on save (#41)"
Task 4: Pin pnpm (#26) + verification
Files: Modify web/package.json, .gitea/workflows/ci.yaml.
- Step 1: Pin pnpm — add a
packageManagerfield toweb/package.jsonmatching the dev/CI version. The local pnpm is11.5.1; CI'spnpm/action-setupis pinned to9— a mismatch. Unify on the local version:- In
web/package.json, add (top level):"packageManager": "pnpm@11.5.1". - In
.gitea/workflows/ci.yaml, change thepnpm/action-setup@v4version: 9→version: 11(matching the major).
- In
- Step 2: Confirm the lockfile is consistent — run
cd web && pnpm install --frozen-lockfile. If it passes, the committedpnpm-lock.yamlis compatible — done. If it FAILS (lockfile format/version mismatch from the pnpm-9→11 change), runpnpm installonce to update the lockfile, confirm only the lockfile changed (git status), and includeweb/pnpm-lock.yamlin the commit. Report which case occurred. - Step 3: Commit:
cd /Users/olsson/Laboratory/biggus-dickus
git add web/package.json .gitea/workflows/ci.yaml web/pnpm-lock.yaml
git commit -m "build(web): pin pnpm via packageManager + align CI (#26)"
Final verification
- Step 4: i18n parity —
cd web
node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const k=o=>Object.entries(o).flatMap(([K,v])=>typeof v==='object'?k(v).map(s=>K+'.'+s):[K]);const ka=k(a).sort(),kb=k(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH '+JSON.stringify({onlyEn:ka.filter(x=>!kb.includes(x)),onlySv:kb.filter(x=>!ka.includes(x))}))"
Expected PARITY OK.
- Step 5: Full suites —
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size
cd /Users/olsson/Laboratory/biggus-dickus
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test --workspace
cargo clippy --workspace --all-targets && cargo +nightly fmt --check
All green; bundle ≤150 KB; clippy/fmt clean.
- Step 6:
git grep -in 'biggus\|dickus' -- crates web/src→ none.
Self-Review (completed)
- Spec coverage: #38 (search visibility enum → T1 backend + T3 prop tighten); #28 (422 field body → T1 backend, T2 FE highlight); #41 (localized_text normalize → T3); #26 (pin pnpm → T4). ✓
- Placeholder scan: none — concrete code; the "read the test harness" notes are verification steps against named files. The
as neverin T2 Step 3 is a typed-RHF-path escape (NOTas any/ts-ignore) and is flagged for lint confirmation. - Type consistency:
FieldErrorView { field, code }(Rust) ↔components["schemas"]["FieldErrorView"](the 422 body openapi-fetch surfaces aserror) ↔FieldRejection{field,code};SearchHitView.visibilityunion flows into the tightenedVisibilityBadgeprop;pruneFieldsnew signature(fields, localizedTextKeys, defaultLang)updated at its single call site.
Notes
- #28 changes the
set_fieldshandler return type fromResult<StatusCode, StatusCode>toResponse; the success status (204) and the field-error status (422) are unchanged — only a body is added to the 422, so existing status-only tests still pass. - #26: if
pnpm install --frozen-lockfileforces a lockfile regen, that's expected and the regeneratedpnpm-lock.yamlis committed; flag if dependency versions shifted.