UX Standards — 07C: UDT Column Visibility and Default Hidden Fields
Governs: Which columns are visible by default, which are hidden, and how users manage column visibility.
Parent rules: See 07-UNIVERSAL-DATA-TABLE.md and 00-OVERVIEW-AND-CSS-RULES.md first.
The Core Rule: Submit All, Display Default
Every field defined in the data model is included in the table definition. Only a curated default subset is visible to the user. Hidden fields remain accessible — users can show them at any time.
This means:
- The backend query fetches ALL fields (or at minimum all non-blob fields)
- The TanStack Table column definitions include ALL fields
defaultColumnVisibilityhides the fields that are technical/audit-only by default- Users can reveal any hidden column via the Column Selector in the toolbar
Field Categories
| Category | Default State | Examples |
|---|---|---|
| Primary display fields | Visible | Name, Status, Email, Type, Code, Created Date |
| Secondary fields | Visible | Phone, Role, Tenant, Flags |
| System ID fields | Hidden | id, uuid, user_uuid, tenant_uuid |
| Audit timestamp fields | Hidden | created_at, updated_at, deleted_at, modified_at |
| Audit user fields | Hidden | created_by, updated_by, deleted_by |
| Internal flags/metadata | Hidden | is_deleted, is_system, version, etag |
| Foreign key references | Hidden | product_type_id, category_uuid (show the joined name instead) |
| Large text / JSON fields | Hidden | notes, metadata, raw_payload, config_json |
Column Definition Pattern
// columns.tsx — all fields defined, defaultColumnVisibility controls what's shown
export const defaultColumnVisibility: Record<string, boolean> = {
// Visible by default (these keys are omitted — visibility defaults to true)
// Hidden by default:
id: false,
uuid: false,
user_uuid: false,
tenant_uuid: false,
created_at: false,
updated_at: false,
deleted_at: false,
created_by: false,
updated_by: false,
is_deleted: false,
is_system: false,
raw_payload: false,
};
export function getColumns(): ColumnDef<UserRecord>[] {
return [
// === CHECKBOX (always first, never hidden) ===
{
id: 'select',
enableHiding: false,
// ...
},
// === PRIMARY VISIBLE FIELDS ===
{
accessorKey: 'username',
header: ({ column }) => <DataTableColumnHeader column={column} title="Username" />,
enableHiding: true,
},
{
accessorKey: 'name',
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
enableHiding: true,
},
{
accessorKey: 'email',
header: ({ column }) => <DataTableColumnHeader column={column} title="Email" />,
enableHiding: true,
},
{
accessorKey: 'status',
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
enableHiding: true,
},
// === HIDDEN BY DEFAULT (but accessible) ===
{
accessorKey: 'id',
header: ({ column }) => <DataTableColumnHeader column={column} title="ID" />,
enableHiding: true, // User CAN show this
// Will be hidden via defaultColumnVisibility
},
{
accessorKey: 'uuid',
header: ({ column }) => <DataTableColumnHeader column={column} title="UUID" />,
enableHiding: true,
},
{
accessorKey: 'created_at',
header: ({ column }) => <DataTableColumnHeader column={column} title="Created At" />,
enableHiding: true,
cell: ({ row }) => (
<span className="text-muted-foreground text-xs">
{formatDateTime(row.getValue('created_at'))}
</span>
),
},
{
accessorKey: 'updated_at',
header: ({ column }) => <DataTableColumnHeader column={column} title="Updated At" />,
enableHiding: true,
},
{
accessorKey: 'created_by',
header: ({ column }) => <DataTableColumnHeader column={column} title="Created By" />,
enableHiding: true,
},
{
accessorKey: 'updated_by',
header: ({ column }) => <DataTableColumnHeader column={column} title="Updated By" />,
enableHiding: true,
},
// === NEVER HIDDEN (actions column) ===
{
id: 'actions',
enableHiding: false,
// ...
},
];
}
TanStack Table Initialization
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
defaultColumnVisibility // Start with the curated default
);
const table = useReactTable({
data,
columns,
state: {
columnVisibility,
// ...other state
},
onColumnVisibilityChange: setColumnVisibility,
// ...
});
Reset to Defaults
The toolbar's Reset & Refresh button restores column visibility to defaultColumnVisibility:
const handleResetAndRefresh = () => {
// Resets column visibility to the default set
if (defaultColumnVisibility && onColumnVisibilityChange) {
onColumnVisibilityChange(defaultColumnVisibility);
}
// Also resets: sorting, filters, selection, search, column order
onRefresh?.();
};
// Pass to DataTableToolbar:
<DataTableToolbar
table={table}
defaultColumnVisibility={defaultColumnVisibility}
defaultColumnOrder={defaultColumnOrder}
onColumnVisibilityChange={setColumnVisibility}
onColumnOrderChange={setColumnOrder}
onRefresh={refetch}
/>
Column Selector UI
Component: DataTableColumnSelector
Trigger icon: Columns3 h-4 w-4 — tooltip: "Manage columns"
The column selector opens a draggable, resizable dialog (ResizableDialogContent) — NOT a popover.
Dialog Specification
| Property | Value |
|---|---|
| Default size | 400 × 450 px |
| Minimum size | 300 × 300 px |
| Maximum size | 600 × 700 px |
| Draggable | Yes — drag the title bar to reposition |
| Resizable | Yes — drag dialog edge/corner |
| Blocks table | No — table remains interactive while open |
Visual Anatomy
┌─────────────────────────────────────────┐
│ ⠿⠿ Manage Columns [✕] │ ← Title bar (draggable), ResizableDialogContent
│ Drag to reorder. Click eye to toggle.│
│ (9 of 12 visible) │
├─────────────────────────────────────────┤
│ [👁 Show All] [🚫 Hide All] [↺ Reset] │ ← Bulk action buttons (outline, size sm)
├─────────────────────────────────────────┤
│ ┌───────────────────────────────────┐ │
│ │ ⠿ select Required │ │ ← canHide=false: shows "Required" label
│ ├───────────────────────────────────┤ │
│ │ ⠿ username Required │ │
│ ├───────────────────────────────────┤ │
│ │ ⠿ fullName 👁 │ │ ← canHide=true: Eye icon = visible
│ ├───────────────────────────────────┤ │
│ │ ⠿ phone_number 👁 │ │
│ ├───────────────────────────────────┤ │
│ │ ⠿ email 👁 │ │
│ ├───────────────────────────────────┤ │
│ │ ⠿ status Required │ │
│ ├───────────────────────────────────┤ │
│ │ ⠿ created_at 🙈 │ │ ← EyeOff = currently hidden
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
Column Item Anatomy
Each row in the column list contains (left to right):
| Element | Component | Description |
|---|---|---|
| Drag handle | GripVertical h-4 w-4 text-muted-foreground | cursor-grab — drag to reorder |
| Column name | <span className="flex-1 truncate text-sm"> | The column label from meta.label or column ID |
| Eye toggle / Required | Eye or EyeOff (h-4 w-4) or <span>Required</span> | Toggle visibility, or locked indicator |
Column item container:
className="flex items-center gap-2 rounded-md border bg-card p-2"
// When dragging: opacity-50 shadow-lg
// When hidden: opacity-60
Eye / Required Logic
// Column with enableHiding: true (default)
{column.isVisible
? <Eye className="h-4 w-4" /> // Visible — click to hide
: <EyeOff className="h-4 w-4" /> // Hidden — click to show
}
// Column with enableHiding: false
<span className="px-1 text-xs text-muted-foreground">Required</span>
// Tooltip: "This column cannot be hidden"
Bulk Action Buttons
Three buttons inside the dialog, above the column list:
| Button | Icon | Action |
|---|---|---|
| Show All | Eye mr-1 h-3 w-3 | Makes all columns visible |
| Hide All | EyeOff mr-1 h-3 w-3 | Hides all canHide=true columns |
| Reset | RotateCcw mr-1 h-3 w-3 | Restores default visibility + default order |
All three are variant="outline" size="sm".
Important Implementation Notes
- Changes apply immediately (
autoApply=trueby default) — no Apply/Cancel needed - If
autoApply=false, a footer with Cancel + "Apply Order" buttons appears - Drag-to-reorder uses
@dnd-kit/core+@dnd-kit/sortablewithverticalListSortingStrategy - All columns appear in the list — including
enableHiding: falseones (they just show "Required" instead of eye icon) - The list is rendered in
columnOrdersequence, so visible and hidden columns are intermixed (no separator) - Changes to column order also propagate via
onColumnOrderChangeprop
enableHiding: false — Columns That Cannot Be Hidden
Some columns must never be hidden:
| Column | Why |
|---|---|
select (checkbox) | Required for row selection to work |
actions | Required for per-row actions |
Primary identifier (e.g., username, code) | User must always be able to identify the row |
Set enableHiding: false in the column definition for these:
{
id: 'select',
enableHiding: false, // Will NOT appear in column selector
}
Columns with enableHiding: false do not appear in the Column Selector popover at all.
Persistence of User Preferences (Optional)
For applications where users expect their column visibility to persist across sessions:
// Save to localStorage
const VISIBILITY_STORAGE_KEY = `table-visibility-${doctype}`;
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(() => {
const saved = localStorage.getItem(VISIBILITY_STORAGE_KEY);
return saved ? JSON.parse(saved) : defaultColumnVisibility;
});
// Persist on change
const handleVisibilityChange = (visibility: VisibilityState) => {
setColumnVisibility(visibility);
localStorage.setItem(VISIBILITY_STORAGE_KEY, JSON.stringify(visibility));
};
Do not persist by default — the Reset & Refresh button must always restore the curated default. Only add persistence when explicitly required.
Audit Field Display Standards
When audit/system fields ARE shown (user has unhidden them), format them appropriately:
// Timestamp columns — relative time + absolute on hover
{
accessorKey: 'created_at',
cell: ({ row }) => {
const date = new Date(row.getValue('created_at'));
return (
<Tooltip>
<TooltipTrigger>
<span className="text-xs text-muted-foreground">
{formatRelativeTime(date)} {/* e.g., "3 days ago" */}
</span>
</TooltipTrigger>
<TooltipContent>
{formatDateTime(date)} {/* e.g., "Jan 15, 2025 at 2:34 PM" */}
</TooltipContent>
</Tooltip>
);
}
}
// UUID columns — truncated with full value on hover
{
accessorKey: 'uuid',
cell: ({ row }) => {
const uuid = row.getValue('uuid') as string;
return (
<Tooltip>
<TooltipTrigger>
<code className="text-xs text-muted-foreground font-mono">
{uuid?.slice(0, 8)}…
</code>
</TooltipTrigger>
<TooltipContent>
<code className="text-xs font-mono">{uuid}</code>
</TooltipContent>
</Tooltip>
);
}
}
Standard Default Column Sets by Page Type
User Management Lists
export const defaultColumnVisibility = {
// Visible: username, name, email, status, tenant, roles, flags, actions
// Hidden:
id: false, uuid: false, user_uuid: false,
created_at: false, updated_at: false, deleted_at: false,
created_by: false, updated_by: false,
is_deleted: false, is_system: false, password_hash: false,
};
Product / Config Lists
export const defaultColumnVisibility = {
// Visible: code, name, type, category, status, actions
// Hidden:
id: false, uuid: false, tenant_uuid: false,
created_at: false, updated_at: false,
created_by: false, updated_by: false,
sort_order: false, internal_notes: false,
};
Transaction / Log Lists
export const defaultColumnVisibility = {
// Visible: date, type, status, source, destination, actions
// Hidden — extremely detailed fields:
id: false, uuid: false, tenant_uuid: false,
raw_payload: false, error_details: false,
created_by: false, updated_by: false,
processing_time_ms: false, retry_count: false,
};
Violation Checklist
-
defaultColumnVisibilitydefined as a constant, not inline in useState - All system/audit/UUID fields are
falseindefaultColumnVisibility -
selectandactionscolumns haveenableHiding: false - Primary identifier column has
enableHiding: false -
defaultColumnVisibilitypassed toDataTableToolbarfor Reset & Refresh - UUID values truncated to 8 chars with full value in tooltip
- Timestamps in audit columns shown as relative time with absolute in tooltip
- Column selector shows visible columns first, hidden second