mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
fix(web): a stored field value String() cannot convert no longer crashes the list, board, table, child items or shared views (BUG-3052 unit 1) (#1553)
An item's field values are JSON of any shape, whatever the declared type. A
stored object like {"toString":0} makes String(v), template interpolation,
new Date(v) and Svelte text conversion throw "Cannot convert object to
primitive value". The census on the BUG-3052 trail found 13 such throw sites.
The worst was laneValue, the helper every grouped view reads lanes through.
This adds the read-side layer the lead ruled for, web/src/lib/fields/fieldShape.ts:
- safeString: exactly String, but total. For a value String cannot convert, it
falls back to the value's JSON text.
- safeText: null/undefined read as '', otherwise safeString.
Routed through it:
- laneValue, which covers list grouping, board buckets and drops, relation
lanes and shared-board grouping;
- the TableView sort and plain text cell;
- the ChildItems group key;
- shareView.matchesFilter and the /s/[token] field formatter;
- QuickActionsMenu and quick-action-preview (categoricalTemplateValue, fields,
plan/phase);
- the playbooks sort and trigger/scope chips;
- three ItemDetail sites;
- ChildChart's parent dates, where only objects are converted and every
primitive reaches new Date untouched.
Output is byte-identical for every value the old code could convert. Rendering
garbage, type-changing writes and misfilters are units 2 and 3; the server
SQL is split out as BUG-3221.
Tests cover each throw class (String conversion, a string method on a
non-string, new Date, Svelte text), plus the helpers' own contract. With every
changed source file swapped back to origin/main, the new legs fail.
This commit is contained in:
@@ -193,3 +193,14 @@ describe('lanes named after an Object.prototype member (BUG-3208)', () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('a stored value String() cannot convert (BUG-3052)', () => {
|
||||
const HOSTILE = JSON.parse('{"toString":0}');
|
||||
it('laneValue does not throw, and names the value by its JSON', () => {
|
||||
expect(laneValue(HOSTILE)).toBe('{"toString":0}');
|
||||
});
|
||||
it('bucketByColumn keeps the item, in Uncategorized', () => {
|
||||
const result = bucketByColumn([item('h', { status: HOSTILE })], 'status', ['open']);
|
||||
expect(result.get(UNCATEGORIZED)!.map((i) => i.id)).toEqual(['h']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
// lane writes back to the field (clearing it).
|
||||
import type { Item } from '$lib/types';
|
||||
import { parseFields } from '$lib/types';
|
||||
import { safeText } from '$lib/fields/fieldShape';
|
||||
|
||||
/** Lane key for items with no (or an unrecognised) group value. */
|
||||
export const UNCATEGORIZED = '';
|
||||
@@ -38,9 +39,11 @@ export const UNCATEGORIZED = '';
|
||||
* is what stops the two halves drifting apart again.
|
||||
*/
|
||||
export function laneValue(raw: unknown): string {
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (raw == null) return '';
|
||||
return String(raw);
|
||||
// `safeText`, not `String` (BUG-3052): a stored `{"toString":0}` made
|
||||
// `String(raw)` throw here, and every grouped view reads its lanes through
|
||||
// this function, so one hostile value took the whole list or board down.
|
||||
// Identical to `String` for every value it could already convert.
|
||||
return safeText(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// BUG-3052 unit 1, the `new Date` throw class: the parent's start/end date are
|
||||
// stored field values, and `new Date({"toString":0})` throws, which took the
|
||||
// whole children section's chart down.
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { flushSync, mount, unmount } from 'svelte';
|
||||
import ChildChart from './ChildChart.svelte';
|
||||
|
||||
const kid = (id: string) =>
|
||||
({ id, slug: id, title: id, collection_slug: 'tasks', fields: '{"status":"open"}',
|
||||
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-02T00:00:00Z' }) as never;
|
||||
|
||||
let instance: ReturnType<typeof mount> | undefined;
|
||||
let target: HTMLElement | undefined;
|
||||
afterEach(() => {
|
||||
if (instance) unmount(instance);
|
||||
instance = undefined;
|
||||
target?.remove();
|
||||
});
|
||||
|
||||
describe('ChildChart with a stored date String() cannot convert (BUG-3052)', () => {
|
||||
it('mounts, rather than throwing', () => {
|
||||
const hostile = JSON.parse('{"toString":0}');
|
||||
target = document.body.appendChild(document.createElement('div'));
|
||||
expect(() => {
|
||||
instance = mount(ChildChart, {
|
||||
target: target!,
|
||||
props: { children: [kid('a'), kid('b')], startDate: hostile, endDate: hostile, wsSlug: 'ws' },
|
||||
});
|
||||
flushSync();
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { safeText } from '$lib/fields/fieldShape';
|
||||
import type { Item } from '$lib/types';
|
||||
import { countedChildren, isChildDone } from '$lib/collections/childProgress';
|
||||
import { collectionStore } from '$lib/stores/collections.svelte';
|
||||
@@ -14,6 +15,15 @@
|
||||
}
|
||||
|
||||
let { children, startDate, endDate, wsSlug }: Props = $props();
|
||||
|
||||
// The parent's `start_date` / `end_date` are stored field values, typed
|
||||
// `string` here but not guaranteed to be one (BUG-3052). `new Date` threw on a
|
||||
// stored `{"toString":0}`. Only an OBJECT (arrays included) is converted, to
|
||||
// its safe text; every primitive — a date string, an epoch number, even a
|
||||
// boolean — reaches `new Date` untouched, exactly as before.
|
||||
function dateInput(v: unknown): unknown {
|
||||
return typeof v === 'object' && v !== null ? safeText(v) : v;
|
||||
}
|
||||
// Each child by its own collection's done field and terminal/abandoned
|
||||
// values. An abandoned child is not part of the work any more and leaves
|
||||
// the burndown entirely (BUG-3195).
|
||||
@@ -49,7 +59,7 @@
|
||||
// ── Determine start date ──────────────────────────────────────────
|
||||
let start: Date;
|
||||
if (startDate) {
|
||||
start = new Date(startDate);
|
||||
start = new Date(dateInput(startDate) as string);
|
||||
} else {
|
||||
// Infer: earliest created_at among the counted children
|
||||
let earliest = new Date(counted[0].created_at);
|
||||
@@ -64,7 +74,7 @@
|
||||
// ── Determine end date ────────────────────────────────────────────
|
||||
let end: Date;
|
||||
if (endDate) {
|
||||
end = new Date(endDate);
|
||||
end = new Date(dateInput(endDate) as string);
|
||||
} else if (allDone && completions.length > 0) {
|
||||
// All counted children done → last completion date
|
||||
end = new Date(completions[completions.length - 1].date);
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { parseFields, parseSchema, formatItemRef } from '$lib/types';
|
||||
import { collectionsNotStaleFor, categoricalValueFor } from '$lib/collections/categoricalFieldValue';
|
||||
import { laneKey } from '$lib/collections/boardColumns';
|
||||
import { safeText } from '$lib/fields/fieldShape';
|
||||
import { dndzone, TRIGGERS, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action';
|
||||
import type { DndEvent } from 'svelte-dnd-action';
|
||||
import {
|
||||
@@ -136,7 +137,12 @@
|
||||
let groups = $derived.by(() => {
|
||||
const map = new SvelteMap<string, Item[]>();
|
||||
for (const child of children) {
|
||||
const status = parseFields(child).status ?? 'open';
|
||||
// `safeText`, not the raw value (BUG-3052): the group key feeds a
|
||||
// heading formatter and a `lane:` key template, both of which threw on a
|
||||
// stored `{"toString":0}`, and an array or object keyed a group by
|
||||
// identity, one group per child.
|
||||
const raw = parseFields(child).status;
|
||||
const status = raw == null ? 'open' : safeText(raw);
|
||||
if (!map.has(status)) map.set(status, []);
|
||||
map.get(status)!.push(child);
|
||||
}
|
||||
|
||||
@@ -224,3 +224,43 @@ describe('a child status that names an Object.prototype member (BUG-3054)', () =
|
||||
for (const label of labels) expect(label, label).toMatch(/\(1\)$/);
|
||||
});
|
||||
});
|
||||
|
||||
// BUG-3052 unit 1: a stored status that is not a string. The group heading
|
||||
// called `.replace` on it (a number has none) and the lane key template
|
||||
// converted it (`{"toString":0}` cannot be converted), so either one threw and
|
||||
// the whole children section failed to render.
|
||||
describe('a child status that is not a string (BUG-3052)', () => {
|
||||
let target: HTMLElement;
|
||||
let instance: ReturnType<typeof mount> | undefined;
|
||||
const kid = (id: string, fields: string) => ({
|
||||
id, slug: id, title: `Child ${id}`, collection_slug: 'tasks', fields,
|
||||
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
childrenMock.mockClear();
|
||||
childrenMock.mockImplementation(async () => [
|
||||
kid('n', '{"status":5}'),
|
||||
kid('h', '{"status":{"toString":0}}'),
|
||||
kid('o', '{"status":"open"}'),
|
||||
]);
|
||||
target = document.body.appendChild(document.createElement('div'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (instance) unmount(instance);
|
||||
instance = undefined;
|
||||
target.remove();
|
||||
childrenMock.mockImplementation(async () => []);
|
||||
});
|
||||
|
||||
it('renders every child and a heading for each status', async () => {
|
||||
instance = mount(ChildItems, { target, props: { wsSlug: 'ws-1', itemSlug: 'p-1', itemId: 'p-1' } });
|
||||
flushSync();
|
||||
await vi.waitFor(() => expect(target.querySelector('.child-count')).not.toBeNull());
|
||||
flushSync();
|
||||
const text = target.textContent ?? '';
|
||||
for (const id of ['n', 'h', 'o']) expect(text, `child ${id} is rendered`).toContain(`Child ${id}`);
|
||||
expect([...target.querySelectorAll('.group-label')]).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -362,3 +362,18 @@ describe('the table chip asks the same permission question as the card (BUG-3068
|
||||
expect(onStatusChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// BUG-3052 unit 1: a plain text cell whose stored value Svelte's text
|
||||
// conversion cannot convert. `{fields[key] ?? ''}` threw and took the table down.
|
||||
describe('a text cell holding a value String() cannot convert (BUG-3052)', () => {
|
||||
it('renders the row, with the value visible as its JSON', () => {
|
||||
const coll = collection([{ key: 'note', label: 'Note', type: 'text' }]);
|
||||
const hostile = { id: 'car-h', workspace_id: 'ws1', collection_id: 'c1', slug: 'car-h', title: 'car-h',
|
||||
content: '', fields: '{"note":{"toString":0}}', tags: '[]', status: 'open',
|
||||
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' } as unknown as Item;
|
||||
const screen = render(TableView, { props: { items: [hostile, item('car-2', { note: 'plain' })], collection: coll } as never });
|
||||
expect(screen.container.textContent).toContain('car-h');
|
||||
expect(screen.container.textContent).toContain('{"toString":0}');
|
||||
expect(screen.container.textContent).toContain('plain');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { safeText } from '$lib/fields/fieldShape';
|
||||
import type { Item, Collection, FieldDef } from '$lib/types';
|
||||
import { isRelationType, relationValuesOf } from '$lib/items/relationFieldTypes';
|
||||
import { narrowRelationRow, relationChipFor, UNRESOLVED_TITLE } from '$lib/collections/relationGroups';
|
||||
@@ -136,8 +137,10 @@
|
||||
return sortDir === 'asc' ? aVal - bVal : bVal - aVal;
|
||||
}
|
||||
|
||||
const aStr = String(aVal).toLowerCase();
|
||||
const bStr = String(bVal).toLowerCase();
|
||||
// safeText (BUG-3052): String threw on a stored `{"toString":0}`, which
|
||||
// broke the whole sort, and so the table.
|
||||
const aStr = safeText(aVal).toLowerCase();
|
||||
const bStr = safeText(bVal).toLowerCase();
|
||||
const cmp = aStr.localeCompare(bStr);
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
@@ -351,7 +354,9 @@
|
||||
<span class="cell-value">{fields[field.key]}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="cell-value">{fields[field.key] ?? ''}</span>
|
||||
<!-- safeText (BUG-3052): Svelte's text conversion threw on a
|
||||
stored `{"toString":0}` and took the table down. -->
|
||||
<span class="cell-value">{safeText(fields[field.key])}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { safeString, safeText } from '$lib/fields/fieldShape';
|
||||
import { localIndex } from '$lib/stores/localIndex.svelte';
|
||||
import { categoricalTemplateValue } from '$lib/utils/quick-action-preview';
|
||||
import { tick, untrack } from 'svelte';
|
||||
@@ -99,10 +100,10 @@
|
||||
collection: collection.name,
|
||||
content: item?.content ? item.content.slice(0, 200) : '',
|
||||
fields: Object.entries(fields)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.map(([k, v]) => `${k}: ${safeString(v)}`)
|
||||
.join(', '),
|
||||
plan: item ? String(fields['plan'] ?? '') : '',
|
||||
phase: item ? String(fields['phase'] ?? fields['plan'] ?? '') : ''
|
||||
plan: item ? safeText(fields['plan']) : '',
|
||||
phase: item ? safeText(fields['phase'] ?? fields['plan']) : ''
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { safeString, safeText } from '$lib/fields/fieldShape';
|
||||
import { page, navigating } from '$app/state';
|
||||
import { tick, onMount, onDestroy, untrack } from 'svelte';
|
||||
import { api, PadApiError, isUpdateConflictError, type ImportURLResponse } from '$lib/api/client';
|
||||
@@ -4770,7 +4771,8 @@
|
||||
// first report of a load is ChildItems' initial fetch, which the load's
|
||||
// own GET /progress already answers, so a load issues no extra request.
|
||||
const sig = items
|
||||
.map((i) => `${i.id}:${parseFields(i).status ?? ''}`)
|
||||
// safeText (BUG-3052): `${}` threw on a stored `{"toString":0}`.
|
||||
.map((i) => `${i.id}:${safeText(parseFields(i).status)}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
const sameLoad = childrenSigGen === loadGeneration;
|
||||
@@ -4804,7 +4806,8 @@
|
||||
|
||||
function formatFieldDisplay(value: any): string {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
return String(value).replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
// safeText (BUG-3052): String threw on a stored `{"toString":0}`.
|
||||
return safeText(value).replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
function relationLabel(ref?: string, title?: string, fallback?: string): string {
|
||||
@@ -5899,7 +5902,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="source-chip"
|
||||
title={`Source: ${fields.pad_source_url}${fields.pad_imported_at ? `\nImported: ${fields.pad_imported_at}` : ''}`}
|
||||
title={`Source: ${safeString(fields.pad_source_url)}${fields.pad_imported_at ? `\nImported: ${safeString(fields.pad_imported_at)}` : ''}`}
|
||||
disabled={refreshing}
|
||||
onclick={refreshFromSource}
|
||||
>
|
||||
|
||||
@@ -198,3 +198,12 @@ describe('groupItems with falsy-but-present values', () => {
|
||||
expect(total).toBe(items.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesFilter on a stored value String() cannot convert (BUG-3052)', () => {
|
||||
it('does not throw, and matches nothing it should not', async () => {
|
||||
const { matchesFilter } = await import('./shareView');
|
||||
const item = { fields: JSON.parse('{"status":{"toString":0}}') } as never;
|
||||
expect(matchesFilter(item, { field: 'status', op: 'eq', value: 'open' })).toBe(false);
|
||||
expect(matchesFilter(item, { field: 'status', op: 'in', value: ['open'] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import type { FieldDef } from '$lib/types';
|
||||
import { isRelationType, relationValuesOf } from '$lib/items/relationFieldTypes';
|
||||
import { UNPARENTED_FILTER_FIELD } from '$lib/collections/unparentedFilter';
|
||||
import { safeString, safeText } from '$lib/fields/fieldShape';
|
||||
|
||||
// Re-exported so existing/future imports of `UNPARENTED_FILTER_FIELD` from
|
||||
// this module keep working — the canonical definition lives in
|
||||
@@ -244,16 +245,19 @@ export function matchesFilter(item: PublicItem, filter: unknown): boolean {
|
||||
if (typeof f.field !== 'string') return true;
|
||||
if (f.field === UNPARENTED_FILTER_FIELD) return true;
|
||||
const fieldVal = item.fields[f.field];
|
||||
// `safeText` / `safeString`, not `String` (BUG-3052): a stored
|
||||
// `{"toString":0}` made `String` throw, which took the whole shared view
|
||||
// down. Same comparisons, same answers, for every value `String` handled.
|
||||
if (f.op === 'eq') {
|
||||
return String(fieldVal ?? '') === String(f.value ?? '');
|
||||
return safeText(fieldVal) === safeText(f.value);
|
||||
}
|
||||
if (f.op === 'in') {
|
||||
const wanted = Array.isArray(f.value) ? f.value.map((v) => String(v)) : [String(f.value)];
|
||||
const wanted = Array.isArray(f.value) ? f.value.map((v) => safeString(v)) : [safeString(f.value)];
|
||||
// Tags-style array fields: match if any item value is wanted.
|
||||
if (Array.isArray(fieldVal)) {
|
||||
return fieldVal.some((v) => wanted.includes(String(v)));
|
||||
return fieldVal.some((v) => wanted.includes(safeString(v)));
|
||||
}
|
||||
return wanted.includes(String(fieldVal ?? ''));
|
||||
return wanted.includes(safeText(fieldVal));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// BUG-3052 unit 1: the read-side text conversion never throws.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { safeString, safeText } from './fieldShape';
|
||||
|
||||
// A value JSON.parse happily produces, on which String() / `${}` / new Date() throw.
|
||||
const HOSTILE = JSON.parse('{"toString":0}');
|
||||
|
||||
describe('safeText', () => {
|
||||
it('control: String() really does throw on the hostile value', () => {
|
||||
// Without this, every leg below could pass on a runtime where the value is
|
||||
// harmless, and measure nothing.
|
||||
expect(() => String(HOSTILE)).toThrow();
|
||||
});
|
||||
|
||||
it('does not throw on the hostile value, and keeps it visible as its JSON', () => {
|
||||
expect(safeText(HOSTILE)).toBe('{"toString":0}');
|
||||
expect(safeText([HOSTILE, 'a'])).toBe('[{"toString":0},"a"]');
|
||||
});
|
||||
|
||||
it('matches String() exactly for every value String() can convert', () => {
|
||||
for (const v of ['open', '', 0, 5, false, true, ['a', 'b'], [], { a: 1 }, [1, [2, 3]]]) {
|
||||
expect(safeText(v), JSON.stringify(v)).toBe(String(v));
|
||||
}
|
||||
});
|
||||
|
||||
it('reads null and undefined as no text', () => {
|
||||
expect(safeText(null)).toBe('');
|
||||
expect(safeText(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeString', () => {
|
||||
it('is String() for every value String() converts, null and undefined included', () => {
|
||||
for (const v of [null, undefined, 'x', 0, true, ['a', 'b'], { a: 1 }]) {
|
||||
expect(safeString(v)).toBe(String(v));
|
||||
}
|
||||
});
|
||||
it('does not throw on the hostile value', () => {
|
||||
expect(safeString(HOSTILE)).toBe('{"toString":0}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// The read-side shape layer for item field values (BUG-3052).
|
||||
//
|
||||
// An item's `fields` is a JSON blob, and a field's DECLARED type is not a
|
||||
// promise about what is STORED: the schema editor sets any type on any key and
|
||||
// nothing rewrites existing values when a type changes. So a reader of
|
||||
// `fields[key]` can be handed a string, number, boolean, null, array or object,
|
||||
// and a JSON object can even carry a non-callable `toString`
|
||||
// (`{"toString":0}`), on which `String(v)`, `${v}` and `new Date(v)` THROW
|
||||
// ("Cannot convert object to primitive value") and take the whole view down.
|
||||
//
|
||||
// This module is where a raw field value is turned into something a surface
|
||||
// can use. It is the checklist as well as the code: the population below is
|
||||
// what the lead ruled should route through here (one layer, not per-surface
|
||||
// guards; no source scanner — the table IS the enforcement).
|
||||
//
|
||||
// ## Population (BUG-3052 census on main 4d4ab1d6; full table on the trail)
|
||||
//
|
||||
// Unit 1 — THROWS (routed through `safeText` in this change):
|
||||
// - laneValue (boardColumns.ts) → list grouping, board buckets + drops,
|
||||
// relation lanes, shareView groupItems
|
||||
// - TableView column sort
|
||||
// - ChildItems group heading + its lane key
|
||||
// - shareView matchesFilter; the /s/[token] field formatter
|
||||
// - QuickActionsMenu + quick-action-preview prompt variables
|
||||
// - playbooks page trigger sort
|
||||
// - ItemDetail: children signature, computed-field text, imported-at title
|
||||
// - ChildChart start/end dates
|
||||
// Unit 2 — renders garbage / writes back a changed type (FieldEditor,
|
||||
// TableView cells): `readAs` + a mismatch marker; waits on a UX ruling.
|
||||
// Unit 3 — web misfilters (strict `===` / `includes` on raw values).
|
||||
// Unit 4 — server SQL, split out as BUG-3221.
|
||||
|
||||
/**
|
||||
* Exactly `String(raw)`, except it never throws. For a call site that used a
|
||||
* bare `String(v)` and whose output for `null` / `undefined` (`'null'`,
|
||||
* `'undefined'`) is part of what it compares — so swapping in `safeText` would
|
||||
* change an answer rather than only remove a crash.
|
||||
*
|
||||
* Identical to `String` whenever `String` would not throw: `'a,b'` for
|
||||
* `['a','b']`, `'[object Object]'` for a plain object, `'0'`, `'false'`. Only the
|
||||
* values `String` cannot convert fall back to their JSON text, so they stay
|
||||
* visible and distinguishable instead of taking the surface down.
|
||||
*/
|
||||
export function safeString(raw: unknown): string {
|
||||
try {
|
||||
return String(raw);
|
||||
} catch {
|
||||
try {
|
||||
return JSON.stringify(raw) ?? '';
|
||||
} catch {
|
||||
return '[unreadable value]';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A field value as display/compare TEXT, for any value JSON can hold. TOTAL:
|
||||
* it never throws. `null` / `undefined` read as `''` (no text) — what every
|
||||
* caller that wrote `String(v ?? '')` or its own null guard wanted — and every
|
||||
* other value reads exactly as `safeString` does.
|
||||
*/
|
||||
export function safeText(raw: unknown): string {
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (raw == null) return '';
|
||||
return safeString(raw);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// BUG-3052 unit 1: the prompt-variable context tolerates stored values String()
|
||||
// cannot convert. `categoricalTemplateValue`, the `fields` line and plan/phase
|
||||
// all converted raw values and threw on `{"toString":0}`.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { contextFromItem } from './quick-action-preview';
|
||||
|
||||
describe('contextFromItem with a hostile stored value (BUG-3052)', () => {
|
||||
it('does not throw, and keeps the value visible as its JSON', () => {
|
||||
const item = {
|
||||
id: 'i1', title: 'T', content: '', item_number: 1,
|
||||
fields: '{"status":{"toString":0},"plan":{"toString":0},"note":"x"}',
|
||||
} as never;
|
||||
const collection = { slug: 'tasks', name: 'Tasks', prefix: 'TASK', schema: '{"fields":[]}' } as never;
|
||||
const ctx = contextFromItem(item, collection);
|
||||
expect(ctx.status).toBe('{"toString":0}');
|
||||
expect(ctx.plan).toBe('{"toString":0}');
|
||||
expect(ctx.fields).toContain('status: {"toString":0}');
|
||||
expect(ctx.fields).toContain('note: x');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Collection, Item, ItemIndexRow } from '$lib/types';
|
||||
import { safeString, safeText } from '$lib/fields/fieldShape';
|
||||
import { formatItemRef, parseFields } from '$lib/types';
|
||||
import { fieldDefFor } from '$lib/collections/categoricalFieldValue';
|
||||
import { isRelationType, relationValuesOf } from '$lib/items/relationFieldTypes';
|
||||
@@ -60,7 +61,7 @@ export function categoricalTemplateValue(
|
||||
): string {
|
||||
const field = collection ? fieldDefFor([collection], collection.slug, key) : undefined;
|
||||
if (!field || !isRelationType(field.type)) {
|
||||
return raw === null || raw === undefined ? '' : String(raw);
|
||||
return safeText(raw);
|
||||
}
|
||||
const ids = relationValuesOf(field.type, raw);
|
||||
const labels: string[] = [];
|
||||
@@ -135,11 +136,13 @@ export function contextFromItem(
|
||||
priority: categoricalTemplateValue(collection, 'priority', fields['priority'], resolve),
|
||||
collection: collection.name,
|
||||
content: item.content ? item.content.slice(0, 200) : '',
|
||||
// safeString / safeText, not `${v}` / String (BUG-3052): a stored
|
||||
// `{"toString":0}` threw here. Same text for every value String handled.
|
||||
fields: Object.entries(fields)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.map(([k, v]) => `${k}: ${safeString(v)}`)
|
||||
.join(', '),
|
||||
plan: String(fields['plan'] ?? ''),
|
||||
phase: String(fields['phase'] ?? fields['plan'] ?? '')
|
||||
plan: safeText(fields['plan']),
|
||||
phase: safeText(fields['phase'] ?? fields['plan'])
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { safeText } from '$lib/fields/fieldShape';
|
||||
import { page } from '$app/state';
|
||||
import { ownValue } from '$lib/utils/ownValue';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -220,7 +221,9 @@
|
||||
// what it was sorted BY, which is the same value wearing a different hat.
|
||||
const sa = ownValue(STATUS_ORDER, declaredStatusOf(fa)) ?? 1, sb = ownValue(STATUS_ORDER, declaredStatusOf(fb)) ?? 1;
|
||||
if (sa !== sb) return sa - sb;
|
||||
const ta = fa.trigger ?? '', tb = fb.trigger ?? '';
|
||||
// safeText (BUG-3052): a non-string trigger has no localeCompare, and a
|
||||
// `{"toString":0}` one threw inside the sort.
|
||||
const ta = safeText(fa.trigger), tb = safeText(fb.trigger);
|
||||
if (ta !== tb) return ta.localeCompare(tb);
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
@@ -584,8 +587,8 @@
|
||||
schema lands. The card still renders, with its title, trigger
|
||||
and steps — the affordance is withheld, not the row. -->
|
||||
{@const status = playbooksCollection ? declaredStatus : ''}
|
||||
{@const trigger = fields.trigger ?? 'manual'}
|
||||
{@const scope = fields.scope ?? 'all'}
|
||||
{@const trigger = fields.trigger == null ? 'manual' : safeText(fields.trigger)}
|
||||
{@const scope = fields.scope == null ? 'all' : safeText(fields.scope)}
|
||||
{@const steps = countSteps(item.content)}
|
||||
{@const isExpanded = expandedId === item.id}
|
||||
<div class="card" class:card-draft={status === 'draft'} class:card-deprecated={status === 'deprecated'}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { safeText } from '$lib/fields/fieldShape';
|
||||
import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
import { api, withRequestDeadline } from '$lib/api/client';
|
||||
@@ -444,9 +445,10 @@
|
||||
});
|
||||
|
||||
function formatFieldValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (Array.isArray(value)) return value.join(', ');
|
||||
return String(value);
|
||||
// `safeText` (BUG-3052): `join` and `String` threw on a stored
|
||||
// `{"toString":0}`, element or whole, and took the shared page down.
|
||||
if (Array.isArray(value)) return value.map((v) => safeText(v)).join(', ');
|
||||
return safeText(value);
|
||||
}
|
||||
|
||||
function formatFieldLabel(key: string): string {
|
||||
|
||||
Reference in New Issue
Block a user