# Edit Mode Layout Support — Implementation Design

## Status: DRAFT v2
## Date: 2026-04-02
## Issue: #598
## Prerequisite: PR #590 (single layout UI), PR #594 (DnD), PR #597 (field editability)

---

## 1. What We're Solving

Phase 1 defines: "same Detail Layout correctly renders View/Edit/Create."

**Edit is the remaining gap.** Today Edit works accidentally — `MetadataRecordForm` checks `f.updateable` regardless of the `Mode` field. But:
- Backend hardcodes `Mode: "View"` on every layout response
- Frontend never requests `?type=Edit`
- `ResolveLayout` still has PR #580's dead Create Layout auto-generation code that would re-trigger separate layout creation if `?type=Create` were sent

---

## 2. Two Distinct Concepts That Must Not Be Conflated

**Resolution type** — which layout row to fetch from `_System_PageLayout`. In the single-layout model, this is always the default Detail layout. `ResolveLayout` should not branch on "Create"/"Edit".

**Response mode** — metadata on the API response telling the frontend which rendering context to use. Set at the service/handler layer, not in the resolver.

The current code conflates them: `layoutType` flows from handler → service → resolver → DB lookup, and the resolver branches on it (line 90-114 of `metadata_layouts.go`). This must be split.

---

## 3. Changes Required

### 3.1 Backend: Clean up ResolveLayout (HIGH — Finding 1)

**Remove the type-specific layout lookup and Create auto-generation** from `metadata_layouts.go:90-114`. This code is leftover from PR #580 (separate Create Layout) which was reverted in PR #590.

```go
// DELETE lines 63-67 (resolveType normalization)
// DELETE lines 90-114 (type-specific lookup + Create auto-generation)
```

**Remove `layoutType` parameter from `ResolveLayout` signature**:
```go
// BEFORE
func (ms *MetadataService) ResolveLayout(ctx, apiName, roleAncestors, profileID, recordTypeID, layoutType string) *models.PageLayout

// AFTER
func (ms *MetadataService) ResolveLayout(ctx, apiName, roleAncestors, profileID, recordTypeID string) *models.PageLayout
```

This cascades to:
- `GetLayout` wrapper (`metadata_layouts.go:43`) — remove `""` arg
- `GetLayoutWithOptions` (`primitives.go:198`) — stop passing layoutType to resolver
- `sobject_describe.go` — remove `""` arg from ResolveLayout call
- All mock repos that implement the interface (if signature is on an interface)

**Also delete** `generateSmartCreateLayout` and `GetLayoutByObjectAndType` — these are dead code from the reverted PR #580 direction.

### 3.2 Backend: transformLayout accepts mode (Finding 3 partial)

```go
// BEFORE
func (s *Service) transformLayout(ctx, internalLayout, objectType string) (*Layout, error) {
    Mode: "View",

// AFTER
func (s *Service) transformLayout(ctx, internalLayout, objectType, mode string) (*Layout, error) {
    if mode == "" { mode = "View" }
    Mode: mode,
```

All callers must pass mode:
- `GetLayoutWithOptions` (`primitives.go:204`) — pass mode derived from handler's `?type=` param
- `GetLayoutByID` (`service.go:83`) — pass `"View"` (admin editor context)
- `GetRecordUI` aggregator (`aggregator.go:76`) — pass `"View"` (record-ui is detail view)

### 3.3 Backend: GetLayoutWithOptions maps type→mode

```go
func (s *Service) GetLayoutWithOptions(ctx, objectType, user, recordTypeID, layoutType string) (*Layout, error) {
    // ... resolve layout (no layoutType passed to resolver) ...
    
    // Map request type to response mode
    mode := "View"
    switch layoutType {
    case "Create": mode = "Create"
    case "Edit":   mode = "Edit"
    }
    
    uiLayout, err := s.transformLayout(ctx, internalLayout, objectType, mode)
```

`layoutType` stays in `GetLayoutWithOptions` signature (handler still passes it), but it's only used for mode mapping, NOT for resolution.

### 3.4 Frontend: getLayout + useLayout accept mode

```typescript
// schemas.ts
export const getLayout = async (objectApiName: string, layoutId?: string, mode?: string) => {
  let url = API_ENDPOINTS.METADATA.LAYOUT(objectApiName);
  const params: string[] = [];
  if (layoutId) params.push(`layoutId=${encodeURIComponent(layoutId)}`);
  if (mode) params.push(`type=${encodeURIComponent(mode)}`);
  if (params.length > 0) url += `?${params.join('&')}`;

// useMetadata.ts
export function useLayout(objectApiName: string, layoutId?: string, mode?: string) {
    queryKey: ['metadata', 'layout', objectApiName, layoutId || 'default', mode || 'View'],
    queryFn: () => metadataAPI.getLayout(objectApiName, layoutId, mode),
```

### 3.5 Frontend: ObjectView passes mode (HIGH — Finding 2)

The key insight: **standard objects in Edit mode get their layout from `uiData` (record-ui aggregator), not from `useLayout`.** The aggregator calls `GetLayout` which calls `transformLayout` with mode="View". So even if we add mode to `useLayout`, standard object Edit pages still get `mode: "View"`.

**Two options:**

**Option A: Make aggregator mode-aware** — Add mode to `GetRecordUI` so it passes mode to the layout fetch. But record-ui is always a View context (you're viewing a record's data + layout).

**Option B: For Edit, prioritize `describeLayout` over `uiData` layout** — Change ObjectView to fetch `describeLayout` with `mode=Edit` when editing, and use it instead of `uiData.layouts`.

**Option B is cleaner** — record-ui is View context by definition. Edit is a different rendering concern.

```typescript
// ObjectView.tsx — change useLayout to also fetch for Edit
const { layout: describeLayout } = useLayout(
    ((shouldFetchRecord && isUnsupportedByUIAPI) || isCreate || isEditing)
        ? (objectApiName || '') : '',
    undefined,
    isCreate ? 'Create' : (isEditing ? 'Edit' : undefined)
);

// Change layout prop: Edit also uses describeLayout
layout={(isCreate || isEditing) ? describeLayout : (layout || describeLayout || undefined)}
```

This means: Create and Edit both explicitly fetch with mode. View uses `uiData.layouts` (for standard objects) or `describeLayout` (for unsupported objects).

### 3.6 Frontend: Edit loading state gate

**Problem**: Current `recordLoading` (`ObjectView.tsx:126-128`) only waits for record data, not layout data. If Edit now relies on `describeLayout` (which is async), the form renders before the layout arrives → user sees flat alphabetical grid flash before sections appear (`MetadataRecordForm.tsx:504` flat fallback).

**Fix**: Include `describeLayout` loading state in the Edit rendering gate:

```typescript
// ObjectView.tsx — add layout loading to the gate
const { layout: describeLayout, loading: layoutLoading } = useLayout(
    ((shouldFetchRecord && isUnsupportedByUIAPI) || isCreate || isEditing)
        ? (objectApiName || '') : '',
    undefined,
    isCreate ? 'Create' : (isEditing ? 'Edit' : undefined)
);

// In the Edit/Create render block, wait for layout too:
const isFormReady = !recordLoading && !(isCreate || isEditing) || ((isCreate || isEditing) && !layoutLoading);
// Only render MetadataRecordForm when isFormReady; show skeleton otherwise
```

This prevents the flat-form flash. View mode is unaffected — it uses `uiData.layouts` which loads with the record.

### 3.7 Frontend: Synthetic layouts accept mode

**Problem**: `buildSyntheticLayout()` (`schemas.ts:27,41`) hardcodes the response without a `mode` field. The synthetic path covers not just Dashboard/Report but also Event and Task (`schemas.ts:448,473`). Under Option B, Edit for Event/Task would fetch `useLayout(..., 'Edit')` but get a synthetic response with no mode — the fix wouldn't apply.

**Fix**: Pass `mode` through to `buildSyntheticLayout`:

```typescript
function buildSyntheticLayout(objectApiName: string, sections: ..., mode?: string): PageLayout {
    return {
        id: 'synthetic_' + objectApiName,
        objectApiName,
        layoutType: 'Full',
        mode: mode || 'View',    // NEW: pass through
        sections: ...
    };
}

// In getLayout, pass mode to synthetic builders:
if (objectLower === "dashboard") {
    return { layout: buildSyntheticLayout(objectApiName, [...], mode) };
}
// Same for report, event, task
```

This ensures Event/Task Edit pages also get `mode: "Edit"` in their layout response.

---

## 4. Files Changed (Updated)

| File | Change | LOC |
|------|--------|-----|
| `metadata_layouts.go` | Remove lines 63-67, 90-114; remove `layoutType` from `ResolveLayout` signature; delete `generateSmartCreateLayout` | -120 |
| `metadata_layouts.go` | Remove `GetLayoutByObjectAndType` call sites | -5 |
| `repo_layout.go` | Delete `GetLayoutByObjectAndType` method | -20 |
| `repositories.go` | Remove `GetLayoutByObjectAndType` from interface | -1 |
| `primitives.go:198` | Stop passing layoutType to resolver | ~2 |
| `primitives.go:204` | Pass mode to `transformLayout` | ~2 |
| `primitives.go:216` | `transformLayout` accepts `mode` param | ~5 |
| `service.go:83` | `GetLayoutByID` passes `"View"` to transform | ~1 |
| `aggregator.go:76` | Passes `"View"` to layout fetch (already default) | ~0 |
| `sobject_describe.go` | Remove `layoutType` from `ResolveLayout` call | ~1 |
| `schemas.ts:418` | `getLayout` accepts `mode`, passes to URL | ~8 |
| `schemas.ts:27,41` | `buildSyntheticLayout` accepts `mode`, passes through | ~5 |
| `schemas.ts:448,473` | Event/Task synthetic paths pass `mode` | ~4 |
| `useMetadata.ts:47` | `useLayout` accepts `mode`, returns `loading` state | ~6 |
| `ObjectView.tsx:143-147` | Pass mode to `useLayout` based on isCreate/isEditing | ~5 |
| `ObjectView.tsx:361` | Edit uses `describeLayout` (not uiData layout) | ~2 |
| `ObjectView.tsx:~250` | Gate Edit/Create render on `layoutLoading` (prevent flat flash) | ~5 |
| 7 mock test files | Remove `layoutType` from `ResolveLayout` mock signatures | ~7 |

**Total: ~50 lines added, ~150 deleted. Net -100 lines.**

---

## 5. Dead Code Removed

| Code | File | Why |
|------|------|-----|
| `generateSmartCreateLayout()` | `metadata_layouts.go` | PR #580 remnant, single-layout model doesn't need it |
| Type-specific layout lookup (lines 90-114) | `metadata_layouts.go` | PR #580 remnant |
| `GetLayoutByObjectAndType()` | `repo_layout.go` | Only used by removed type-specific lookup |
| `GetLayoutByObjectAndType` interface method | `repositories.go` | Follows repo deletion |
| `GetLayoutByObjectAndType` on 7 mock repos | Test files | Follows interface deletion |
| `resolveType` normalization (lines 63-67) | `metadata_layouts.go` | No longer needed |

---

## 6. What Does NOT Change

- `MetadataRecordForm` — already checks `isEdit ? editableForUpdate : editableForNew`. No change.
- Layout Editor — stays View mode. Admin edits layout content, not mode behavior.
- `FieldAccessResolver` — already handles create vs update paths.
- `HandleLayout` handler — `?type=` validation stays. Value flows to `GetLayoutWithOptions` for mode mapping.
- Cache key in `GetLayoutWithOptions` — already includes `layoutType` which becomes the mode discriminator.
- `editableForNew`/`editableForUpdate` computation — already correct from PR #597.

---

## 7. Test Plan

| Test | What to verify |
|------|---------------|
| API: `GET /ui-api/layout/campaign?type=Edit` | `mode: "Edit"`, same sections as View |
| API: `GET /ui-api/layout/campaign?type=Create` | `mode: "Create"`, same sections |
| API: `GET /ui-api/layout/campaign` | `mode: "View"` (default) |
| API: `?type=Create` does NOT auto-generate separate layout | No new rows in `_System_PageLayout` |
| Browser: Edit Lead via button | Layout has sections (not flat), mode=Edit in API |
| Browser: New Campaign | mode=Create in API, Name/Owner visible |
| Browser: View record | mode=View (default) |
| Go unit tests | All pass after signature changes |
| TypeScript | Zero errors |

---

## 8. Risks

| Risk | Mitigation |
|------|-----------|
| Removing `generateSmartCreateLayout` breaks something | Function was PR #580 code that #590 already made unreachable from frontend; only triggered by `?type=Create` which frontend doesn't send |
| Standard object Edit gets wrong layout | Option B ensures Edit explicitly fetches with mode=Edit via `describeLayout`, bypassing record-ui |
| Cache pollution between modes | Cache key already includes layoutType/mode — separate entries |
