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
6 changes: 5 additions & 1 deletion app/dashboard/_components/UpdateSettingsValue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,11 @@ export default function UpdateSettingsValue({
<InputField
value={newValue}
onChange={handleChange}
onFocus={(event) => event.target.select()}
onFocus={(event) => {
if (event.target instanceof HTMLInputElement) {
event.target.select();
}
}}
type="text"
className="w-full"
placeholder={placeholder}
Expand Down
3 changes: 2 additions & 1 deletion components/settings/SettingsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ export default function SettingsCard({
level="h4"
variant="all-caps"
margin="none"
className={cx(divideChildren ? 'mb-4' : 'mb-2')}
// margin="none" applies m-0! (important), so spacing must use padding
className={cx(divideChildren ? 'pb-4' : 'pb-2')}
>
{title}
</Heading>
Expand Down
3 changes: 2 additions & 1 deletion components/settings/SettingsNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ export default function SettingsNavigation({
)}
noContainer
>
<Heading level="h4" variant="all-caps" margin="none" className="mb-3">
{/* margin="none" applies m-0! (important), so spacing must use padding */}
<Heading level="h4" variant="all-caps" margin="none" className="pb-3">
On this page
</Heading>
<ul className="space-y-0.5">
Expand Down
16 changes: 8 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,13 @@
"@aws-sdk/client-s3": "^3.1068.0",
"@aws-sdk/s3-request-presigner": "^3.1068.0",
"@base-ui/react": "^1.5.0",
"@codaco/fresco-ui": "^3.0.1",
"@codaco/interview": "^2.0.1",
"@codaco/network-exporters": "^1.1.2",
"@codaco/protocol-utilities": "^2.1.1",
"@codaco/protocol-validation": "^11.8.1",
"@codaco/shared-consts": "5.4.0",
"@codaco/tailwind-config": "^1.0.2",
"@codaco/fresco-ui": "^4.0.0",
"@codaco/interview": "^3.0.0",
"@codaco/network-exporters": "^1.1.3",
"@codaco/protocol-utilities": "^2.2.0",
"@codaco/protocol-validation": "^11.9.0",
"@codaco/shared-consts": "5.5.0",
"@codaco/tailwind-config": "^1.1.0",
"@paralleldrive/cuid2": "^3.3.0",
"@posthog/nextjs-config": "^1.9.68",
"@prisma/adapter-neon": "^7.8.0",
Expand Down Expand Up @@ -123,7 +123,7 @@
"sass": "^1.101.0",
"sass-embedded": "^1.100.0",
"storybook": "^10.4.4",
"tailwindcss": "4.3.1",
"tailwindcss": "4.3.2",
Comment thread
jthrilly marked this conversation as resolved.
"tailwindcss-animate": "^1.0.7",
"tsx": "^4.22.4",
"typescript": "6.0.3",
Expand Down
240 changes: 131 additions & 109 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

262 changes: 262 additions & 0 deletions scripts/__tests__/migrate-protocols-to-v8.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,268 @@ describe('migrateProtocolsToV8', () => {
expect(call.data.codebook.node.person).not.toHaveProperty('iconVariant');
});

it('preserves authored v8 state (node shape, required semantics) when normalizing', async () => {
// A mislabelled-v8 body: legacy pre-v8 field shapes (iconVariant, Toggle
// options) force re-normalization, but the row ALSO carries authored v8
// state the v7→v8 migration would destroy. Since protocol-validation
// 11.9.0 tightened CurrentProtocolSchema, rows like this can newly fail
// conformance, and normalization must not corrupt the authored values.
//
// Required-flag semantics mirror what Fresco's legacy form engine
// actually did: minLength/minSelected failed empty values (so the
// migration's required:true preserves observed behaviour and must stay),
// while minValue passed empty values (so a min-valued variable without
// `required` was optional and must remain so). Explicit `required` values
// are always restored.
const mixed = makeV7Protocol();
const personNode = mixed.codebook.node.person as Record<string, unknown>;
personNode.shape = { default: 'square' };
Object.assign(personNode.variables as Record<string, unknown>, {
nickname: {
name: 'nickname',
type: 'text',
component: 'Text',
validation: { minLength: 2 },
},
age: {
name: 'age',
type: 'number',
component: 'Number',
validation: { minValue: 1 },
},
bio: {
name: 'bio',
type: 'text',
component: 'TextArea',
validation: { minLength: 5, required: false },
},
});

const prisma = makeMockPrisma();
prisma.protocol.findMany.mockResolvedValue([
{
id: 'cm-authored-v8',
assets: [],
name: 'Authored.netcanvas',
schemaVersion: 8,
stages: mixed.stages,
codebook: mixed.codebook,
experiments: null,
description: mixed.description,
lastModified: new Date(mixed.lastModified),
},
]);

await migrateProtocolsToV8(
prisma as unknown as Parameters<typeof migrateProtocolsToV8>[0],
);

expect(prisma.protocol.update).toHaveBeenCalledTimes(1);

type UpdateCallArg = {
data: {
codebook: {
node: {
person: {
icon: string;
shape: Record<string, unknown>;
variables: Record<
string,
{ validation?: Record<string, unknown> }
>;
};
};
};
};
};
const call = prisma.protocol.update.mock.calls[0]?.[0] as UpdateCallArg;
const person = call.data.codebook.node.person;

// Legacy shapes were still normalized...
expect(person.icon).toBe('add-a-person');
expect(person).not.toHaveProperty('iconVariant');
// ...but the authored shape config survives instead of being reset to
// { default: 'circle' }...
expect(person.shape).toEqual({ default: 'square' });
// ...minLength keeps the migration's required:true (legacy engine failed
// empty values, so the field was effectively required)...
expect(person.variables.nickname?.validation).toEqual({
minLength: 2,
required: true,
});
// ...minValue-only stays optional (legacy engine passed empty values)...
expect(person.variables.age?.validation).toEqual({ minValue: 1 });
// ...and an explicit required value is restored verbatim.
expect(person.variables.bio?.validation).toEqual({
minLength: 5,
required: false,
});
});

it('normalizes asset-referencing protocols without discarding authored state', async () => {
// The whole-protocol schema cross-references roster/geospatial dataSource
// ids against the asset manifest. Both the conformance check and the
// restored-body re-parse must reconstruct the manifest from the Asset
// rows, or every asset-referencing protocol would re-normalize on each
// deploy and lose its authored state to the fallback path.
const mixed = makeV7Protocol();
const personNode = mixed.codebook.node.person as Record<string, unknown>;
personNode.shape = { default: 'square' };
mixed.stages.push({
id: 'stage-roster',
type: 'NameGeneratorRoster',
label: 'Roster',
subject: { entity: 'node', type: 'person' },
dataSource: 'asset-roster-1',
prompts: [{ id: 'p1', text: 'Who?' }],
} as unknown as (typeof mixed.stages)[number]);

const prisma = makeMockPrisma();
prisma.protocol.findMany.mockResolvedValue([
{
id: 'cm-asset-v8',
assets: [
{
assetId: 'asset-roster-1',
name: 'roster.csv',
type: 'network',
value: null,
},
],
name: 'AssetProtocol.netcanvas',
schemaVersion: 8,
stages: mixed.stages,
codebook: mixed.codebook,
experiments: null,
description: mixed.description,
lastModified: new Date(mixed.lastModified),
},
]);

await migrateProtocolsToV8(
prisma as unknown as Parameters<typeof migrateProtocolsToV8>[0],
);

expect(prisma.protocol.update).toHaveBeenCalledTimes(1);

type UpdateCallArg = {
data: {
codebook: { node: { person: { shape: Record<string, unknown> } } };
};
};
const call = prisma.protocol.update.mock.calls[0]?.[0] as UpdateCallArg;
// Restoration succeeded (no fallback to the shape-resetting plain output)
// even though the protocol references a stored asset.
expect(call.data.codebook.node.person.shape).toEqual({
default: 'square',
});
});

it('skips conformant asset-referencing protocols instead of re-normalizing them each deploy', async () => {
const v7 = makeV7Protocol();
v7.stages.push({
id: 'stage-roster',
type: 'NameGeneratorRoster',
label: 'Roster',
subject: { entity: 'node', type: 'person' },
dataSource: 'asset-roster-1',
prompts: [{ id: 'p1', text: 'Who?' }],
} as unknown as (typeof v7.stages)[number]);
const conformant = migrateProtocol(
{
...v7,
name: 'CleanAssets',
assetManifest: {
'asset-roster-1': {
id: 'asset-roster-1',
name: 'roster.csv',
type: 'network',
source: 'roster.csv',
},
},
},
8,
{ name: 'CleanAssets' },
);

const prisma = makeMockPrisma();
prisma.protocol.findMany.mockResolvedValue([
{
id: 'cm-clean-assets',
assets: [
{
assetId: 'asset-roster-1',
name: 'roster.csv',
type: 'network',
value: null,
},
],
name: 'CleanAssets.netcanvas',
schemaVersion: 8,
stages: conformant.stages,
codebook: conformant.codebook,
experiments: conformant.experiments ?? null,
description: null,
lastModified: new Date('2024-01-01T00:00:00.000Z'),
},
]);

await migrateProtocolsToV8(
prisma as unknown as Parameters<typeof migrateProtocolsToV8>[0],
);

expect(prisma.protocol.update).not.toHaveBeenCalled();
});

it('falls back to the plain re-migration output when authored state cannot be restored', async () => {
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);

// The authored shape config is invalid v8, so restoring it would produce
// a body that fails the strict schema; normalization must persist the
// valid migration output instead of writing a broken body back.
const mixed = makeV7Protocol();
(mixed.codebook.node.person as Record<string, unknown>).shape = {
default: 'not-a-real-shape',
};

const prisma = makeMockPrisma();
prisma.protocol.findMany.mockResolvedValue([
{
id: 'cm-bad-authored',
assets: [],
name: 'BadAuthored.netcanvas',
schemaVersion: 8,
stages: mixed.stages,
codebook: mixed.codebook,
experiments: null,
description: mixed.description,
lastModified: new Date(mixed.lastModified),
},
]);

await migrateProtocolsToV8(
prisma as unknown as Parameters<typeof migrateProtocolsToV8>[0],
);

expect(prisma.protocol.update).toHaveBeenCalledTimes(1);

type UpdateCallArg = {
data: {
codebook: { node: { person: { shape: Record<string, unknown> } } };
};
};
const call = prisma.protocol.update.mock.calls[0]?.[0] as UpdateCallArg;
expect(call.data.codebook.node.person.shape).toEqual({
default: 'circle',
});
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('BadAuthored.netcanvas'),
);
warnSpy.mockRestore();
});

it('conformant schemaVersion-8 protocols are left untouched', async () => {
// Start from a fully-migrated v8 protocol so it already satisfies the strict
// schema; the migration must skip it (no re-write, no hash churn).
Expand Down
Loading
Loading