Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/8395-detail-copy-object-values.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@object-ui/plugin-detail': minor
---

`DetailSection`'s click-to-copy no longer writes `[object Object]` for object-valued
cells (objectui#8395).

The copy affordance built its payload with `String(value)`, and `String()` of an object
is the literal text `[object Object]`. Because the affordance is offered for every
non-empty value — objects included — a reader clicking the copy button, the row, or
pressing Enter on an **address**, **geolocation**, **JSON**, **file/attachment**,
**expanded lookup**, **repeater** or **image** cell silently got that placeholder on the
clipboard, while the cell beside the button rendered the same value correctly. Nothing
errored; it was noticed only on paste.

Objects are now serialized with `JSON.stringify`, so those cells copy the stored value
losslessly and parseably.

**Non-objects are byte-identical.** A number still copies `16` (not the rendered
`16.00`), a currency `1234.5` (not `1,234.50`), a percent `0.123` (not `12%`), a date
`2026-03-04` (not `Mar 4`), and a select its stored `won` (not `Closed Won`). Copying
the *rendered* text was measured and rejected: it is the worse contract for 9 of 17
field types and loses data silently.

**One payload moves without having been broken:** a multiselect stored as
`['alpha','beta']` copied `alpha,beta` and now copies `["alpha","beta"]` — an array is
an object. The new form is lossless where the old one was ambiguous for any value
containing a comma.

The JSON blob is a **defensible default, not a settled contract**. What an object cell
*should* copy (a formatted postal address, `lat, lng`, a filename) is a per-kind product
question tracked separately as objectui#8395's option B; the read site says so in place
so the default is not mistaken for the answer. The `password` / `secret` branch of the
same handler is untouched here — it is its own open card.
56 changes: 55 additions & 1 deletion packages/plugin-detail/src/DetailSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,62 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
*/
const serverFieldErrors = useInlineEdit()?.fieldErrors ?? null;

/**
* What the copy affordance WRITES (objectui#8395).
*
* `String(value)` on an object is the literal text `[object Object]`, so
* every object-valued cell — address, geolocation, JSON, file, expanded
* lookup, repeater — silently put a placeholder on the clipboard, while the
* cell BESIDE the button rendered that same value correctly. Objects are
* serialized as JSON here instead: lossless, parseable, never
* `[object Object]`.
*
* ## The JSON blob is a DEFENSIBLE DEFAULT, NOT A SETTLED CONTRACT
*
* What an object cell *should* put on the clipboard is a product question
* with several defensible answers per kind — the formatted postal address
* the reader can see, `lat, lng` for a geolocation, a filename for a file,
* the option labels or the stored values for a multiselect. That contract is
* objectui#8395's OPTION B (a shared value-to-text formatter that REUSES the
* cell renderers' own formatters — `formatAddress`, objectui#4037 — rather
* than re-spelling them), and it is a SEPARATE, still-unspecified card.
* ⛔ Do not read this line as the answer to it.
*
* ## Two shapes were measured and REJECTED — do not reach for them
*
* Copying the cell's RENDERED text is the worse contract for 9 of 17 field
* types and loses data silently: `date` renders `Mar 4` (the year is gone),
* `percent` renders `12%` against a stored `0.123` (a different quantity),
* `datetime` concatenates to an unparseable `3/4/20265:06 am`, and `image`
* and `boolean` render no text at all — so it would copy the empty string,
* which is strictly worse than the defect it set out to fix. And withdrawing
* the affordance from text-less cells would narrow `canCopy` away from
* `hasCellValue`, whose three readers MUST agree (see its docblock above).
*
* ## The non-regression half
*
* Non-objects keep `String(value)` BYTE-FOR-BYTE: a number still copies
* `16`, never the rendered `16.00`; a select still copies its stored `won`,
* never the rendered `Closed Won`. Pinned per kind — both halves — in
* `__tests__/DetailSection.copyObjectValues-8395.test.tsx`.
*/
const handleCopyField = React.useCallback((fieldName: string, value: any) => {
const textValue = value !== null && value !== undefined ? String(value) : '';
let textValue: string;
if (value === null || value === undefined) {
textValue = '';
} else if (typeof value === 'object') {
// The same guard `JsonCellRenderer` already applies to this exact
// operation on this exact value: a structure `JSON.stringify` cannot
// represent (a cycle) keeps today's string form rather than throwing out
// of a click handler, and the row stays consistent with its own cell.
try {
textValue = JSON.stringify(value);
} catch {
textValue = String(value);
}
} else {
textValue = String(value);
}
navigator.clipboard.writeText(textValue).then(() => {
setCopiedField(fieldName);
setTimeout(() => setCopiedField(null), 2000);
Expand Down
Loading
Loading