Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
245 changes: 136 additions & 109 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

118 changes: 118 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,124 @@ describe('migrateProtocolsToV8', () => {
expect(call.data.codebook.node.person).not.toHaveProperty('iconVariant');
});

it('preserves authored v8 state (node shape, optional min-validated fields) 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 — a custom node shape config
// and a min-validated variable without an explicit `required`. Since
// protocol-validation 11.9.0 tightened CurrentProtocolSchema, rows like
// this can newly fail conformance, and normalization must not corrupt the
// authored values.
const mixed = makeV7Protocol();
const personNode = mixed.codebook.node.person as Record<string, unknown>;
personNode.shape = { default: 'square' };
(personNode.variables as Record<string, unknown>).nickname = {
name: 'nickname',
type: 'text',
component: 'Text',
validation: { minLength: 2 },
};

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' });
// ...and the min-validated variable stays optional instead of gaining
// required: true.
expect(person.variables.nickname?.validation).toEqual({ minLength: 2 });
});

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
123 changes: 114 additions & 9 deletions scripts/migrate-protocols-to-v8.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,20 +147,96 @@ async function migrateOneProtocol(
);
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/**
* Copy the original `validation.required` state (including its absence) back
* onto migrated variables. The v7→v8 migration adds `required: true` to any
* variable carrying a min* rule without an explicit `required`, which is a
* v8-legal authored configuration that must survive re-normalization.
*/
function restoreRequiredFlags(origVars: unknown, candVars: unknown): void {
if (!isRecord(origVars) || !isRecord(candVars)) return;

for (const [varId, candVar] of Object.entries(candVars)) {
const origVar = origVars[varId];
if (!isRecord(origVar) || !isRecord(candVar)) continue;

const origValidation = origVar.validation;
const candValidation = candVar.validation;
if (!isRecord(origValidation) || !isRecord(candValidation)) continue;

if ('required' in origValidation) {
candValidation.required = origValidation.required;
} else {
delete candValidation.required;
Comment thread
jthrilly marked this conversation as resolved.
}
}
}

/**
* Re-running the v7→v8 migration on a body that is already v8-authored is not
* idempotent: it unconditionally resets every node type's `shape` config to
* `{ default: 'circle' }` and adds `required: true` next to min* validators.
* On a mislabelled-v8 row those are authored v8 values, not legacy shapes, so
* copy them back from the stored codebook. Original values are only restored
* where they are structurally plausible (object shape configs, existing
* validation objects); the caller re-parses the result against the strict
* schema before persisting, so a bad restoration can never be written.
*/
function restoreAuthoredV8State(
origCodebook: Record<string, unknown>,
candCodebook: Record<string, unknown>,
): void {
for (const entity of ['node', 'edge'] as const) {
const origEntity = origCodebook[entity];
const candEntity = candCodebook[entity];
if (!isRecord(origEntity) || !isRecord(candEntity)) continue;

for (const [typeId, candType] of Object.entries(candEntity)) {
const origType = origEntity[typeId];
if (!isRecord(origType) || !isRecord(candType)) continue;

if (entity === 'node' && isRecord(origType.shape)) {
candType.shape = origType.shape;
}

restoreRequiredFlags(origType.variables, candType.variables);
}
}

const origEgo = origCodebook.ego;
const candEgo = candCodebook.ego;
if (isRecord(origEgo) && isRecord(candEgo)) {
restoreRequiredFlags(origEgo.variables, candEgo.variables);
}
}

/**
* Normalize a protocol stored as schemaVersion 8 whose body still carries
* pre-v8 field shapes (e.g. object-form `automaticLayout`), which fail the
* strict read-time schema. Re-running the v7→v8 migration rewrites those
* shapes; the protocol's existing v8-only `experiments` are preserved rather
* than reset to the migration default. Throws if the content is genuinely
* invalid and cannot be migrated.
* Normalize a protocol stored as schemaVersion 8 whose body fails the strict
* read-time schema — either because it still carries pre-v8 field shapes
* (e.g. object-form `automaticLayout`) or because it was imported under a
* laxer historical validator (e.g. out-of-palette OrdinalBin colors or an
* orphaned CategoricalBin `otherOptionLabel`, both tightened in
* protocol-validation 11.9.0). Re-running the v7→v8 migration rewrites those
* shapes; authored v8-only state the re-migration would destroy (node shape
* configs, optional-field semantics, `experiments`) is preserved from the
* stored row. Throws if the content is genuinely invalid and cannot be
* migrated.
*/
async function normalizeMislabelledV8(
prisma: Prisma.TransactionClient,
row: ProtocolRow,
): Promise<void> {
const cleanName = row.name.replace(/\.netcanvas$/i, '');

// migrateProtocol shares nested objects with its input and mutates some of
// them in place (e.g. setting `required` on a variable's validation), so
// snapshot the stored codebook before migrating to keep a true original.
const origCodebook: unknown = structuredClone(row.codebook);

const asV7 = {
name: cleanName,
schemaVersion: 7,
Expand All @@ -171,17 +247,46 @@ async function normalizeMislabelledV8(

const migrated = migrateProtocol(asV7, 8, { name: cleanName });

// Restore authored v8 state onto a clone of the migration output, then keep
// the restored body only if it still satisfies the strict schema.
let finalProtocol: Pick<typeof migrated, 'stages' | 'codebook'> = migrated;
const candidate: unknown = structuredClone(migrated);
if (
isRecord(candidate) &&
isRecord(candidate.codebook) &&
isRecord(origCodebook)
) {
restoreAuthoredV8State(origCodebook, candidate.codebook);

const reparsed = CurrentProtocolSchema.safeParse({
Comment thread
jthrilly marked this conversation as resolved.
name: cleanName,
schemaVersion: 8,
stages: candidate.stages,
codebook: candidate.codebook,
experiments: row.experiments ?? {},
});

if (reparsed.success) {
finalProtocol = reparsed.data;
} else {
console.warn(
`Could not preserve authored v8 state while normalizing "${row.name}" ` +
`(id=${row.id}); persisting the plain re-migration output instead.`,
);
}
}

// The hash is derived from stages + codebook only, so re-normalizing to v8
// gives the same hash the import flow would now compute for this protocol.
const newHash = hashProtocol(migrated);
const newHash = hashProtocol({ ...migrated, ...finalProtocol });

await writeMigratedProtocol(
prisma,
row,
{
schemaVersion: 8,
stages: migrated.stages as Prisma.InputJsonValue,
codebook: migrated.codebook,
stages: finalProtocol.stages as Prisma.InputJsonValue,
codebook: finalProtocol.codebook,
// Preserve the protocol's existing v8-only experiments; a v7→v8 migration
// has no knowledge of them and would otherwise reset them to its default.
experiments: row.experiments ?? Prisma.JsonNull,
Expand Down
Loading