Files
biggus-dickus/docs/superpowers/plans/2026-06-05-followups-batch.md
2026-06-05 15:29:35 +02:00

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_fields handler to return a body on the field-error 422s. Its signature is -> Result<StatusCode, StatusCode>; change to -> axum::response::Response and build responses (import axum::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::FieldErrorView in crates/api/src/openapi.rs components(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}/fields with an unknown field key → assert 422 and 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, the unknown case 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.visibilitycomponents["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.fieldRejected to BOTH en.json and sv.json (interpolated):

    • en form: "fieldRejected": "The field \"{{field}}\" was rejected — check its value"
    • sv form: "fieldRejected": "Fältet \"{{field}}\" avvisades — kontrollera värdet"
  • Step 2: A typed rejection in useSetFields — in web/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 formobject-form.tsx owns the react-hook-form instance. Add an optional prop fieldErrorKey?: string | null and, via useEffect, 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.tsx and object-edit-form.tsx, the catch currently does setError(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")); } (import FieldRejection from ../api/queries).
    • Pass fieldErrorKey={fieldErrorKey} to <ObjectForm>.
    • Clear setFieldErrorKey(null) at the top of onSubmit (alongside setError(null)). (For object-edit-form.tsx, which also reads a location.state.fieldsError flag, keep that path but layer the new typed handling on top.)
  • Step 5: Test — add a test (in the object form/new-page test file, MSW) where PUT /api/admin/objects/:id/fields returns 422 with { 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-invalid or 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 from string to 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 pruneFields at its call site (const fields = pruneFields(data.fields, localizedTextKeys, default_language);).
    • Update pruneFields to 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;
      }
      
  • Step 3: Test — add/extend a test: an object whose localized_text field value is { en: "Old", sv: "Ny" }, edited on an sv-default instance, submits fields containing only { <key>: { sv: "Ny" } } (the en key stripped). Use the object-form test harness (the definitions fixture has a localized_text field — 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 packageManager field to web/package.json matching the dev/CI version. The local pnpm is 11.5.1; CI's pnpm/action-setup is pinned to 9 — 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 the pnpm/action-setup@v4 version: 9version: 11 (matching the major).
  • Step 2: Confirm the lockfile is consistent — run cd web && pnpm install --frozen-lockfile. If it passes, the committed pnpm-lock.yaml is compatible — done. If it FAILS (lockfile format/version mismatch from the pnpm-9→11 change), run pnpm install once to update the lockfile, confirm only the lockfile changed (git status), and include web/pnpm-lock.yaml in 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 never in T2 Step 3 is a typed-RHF-path escape (NOT as any/ts-ignore) and is flagged for lint confirmation.
  • Type consistency: FieldErrorView { field, code } (Rust) ↔ components["schemas"]["FieldErrorView"] (the 422 body openapi-fetch surfaces as error) ↔ FieldRejection{field,code}; SearchHitView.visibility union flows into the tightened VisibilityBadge prop; pruneFields new signature (fields, localizedTextKeys, defaultLang) updated at its single call site.

Notes

  • #28 changes the set_fields handler return type from Result<StatusCode, StatusCode> to Response; 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-lockfile forces a lockfile regen, that's expected and the regenerated pnpm-lock.yaml is committed; flag if dependency versions shifted.