/** * @file * * Check appClientData and report any errors, along with fixes if possible. */ import { extractPathParts, isRemoteUrl, flow, produceWithPatches, type Patch, } from '@knapsack/utils'; import type { KsAppClientDataNoMeta } from '@knapsack/types'; type DoctorErrorName = | 'BLOCK_ID_NOT_FOUND' | 'DEMO_ID_NOT_FOUND' | 'NAV_ORPHANED_NO_PARENT' | 'NAV_ITEM_PAGE_MISSING' | 'NAV_PATH_BAD_FORMAT' | 'NAV_ID_BAD_FORMAT' // If the key within byId doesn't match the ID in the object. Rare, but could // happen with manual editing of yaml/json. | 'KEY_ID_MISMATCH'; /** * Use `instanceof` errors in our array of returned DoctorErrors to figure out * what's wrong with appClientData */ export class DoctorError extends Error { // Discriminate the kind of error override name: DoctorErrorName; // Human readable message about what's wrong and how to find it override message: string; // Should we associate the actual fixes for this error instead of separate key // on the return far below? Currently, we're not associating a fix with an error. // If so, here's a suggestion vv: // fixes: Patch[]; constructor({ name, message }: { name: DoctorErrorName; message: string }) { // May as well send message to parent Error, which expects a string super(message); this.name = name; this.message = message; } } /** * Run through appClientData and find any errors that need to be addressed. */ export const v4DetectUi = ({ data, }: { data: Partial; }): { data: Partial; errors: DoctorError[]; fixPatches: Patch[]; } => { const errors: DoctorError[] = []; // Wrap all logic in immer so we only draft the massive appClientData once. Do // we need inversePatches for possible undo later? const [_, fixPatches] = produceWithPatches(data, (draft) => { // Since db is optional, but all our doctor checks rely on it, throw to guard if (!draft.db) throw new Error('No db found in appClientData.'); // Preprocess appClientData so that ID lookups are very fast const blockIds = Object.entries(draft.db.blocks.byId || {}).reduce( (acc, [key, obj]) => { if (key !== obj.id) { errors.push( new DoctorError({ name: 'KEY_ID_MISMATCH', message: `Block ID [${obj.id}] does not match key [${key}] in db.blocks.byId. A fix patch has been generated automatically to remove the block item.`, }), ); delete draft.db.blocks.byId[key]; return acc; } acc.push(key); return acc; }, [], ); const demoIds = Object.entries(draft.db.demos.byId).reduce( (acc, [key, obj]) => { if (key !== obj.id) { errors.push( new DoctorError({ name: 'KEY_ID_MISMATCH', message: `Demo ID [${obj.id}] does not match key [${key}] in db.demos.byId. A fix patch has been generated automatically to remove the demo item.`, }), ); delete draft.db.demos.byId[key]; return acc; } acc.push(key); return acc; }, [], ); // Patterns const patternIds = Object.entries( draft.patternsState.patterns || {}, ).reduce((acc, [key, obj]) => { if (key !== obj.id) { errors.push( new DoctorError({ name: 'KEY_ID_MISMATCH', message: `Pattern ID [${obj.id}] does not match key [${key}] in db.patternsState.patterns. A fix patch has been generated automatically to remove the pattern item.`, }), ); delete draft.patternsState.patterns[key]; return acc; } acc.push(key); return acc; }, []); // patternState.patterns Object.values(draft?.patternsState?.patterns || []).forEach((pattern) => { // patternState.patterns => pattern.templates[] (pattern.templates || []).forEach((template) => { // patternState.patterns[] => pattern.templates[] => blockIds (template.blockIds || []).forEach((blockId, bIndex) => { if (blockIds.includes(blockId)) return; // 1. Error errors.push( new DoctorError({ name: 'BLOCK_ID_NOT_FOUND', message: `Block ID [${blockId}] referenced within pattern [${pattern.id}] template [${template.id}] not found in db.blocks.byId. A fix patch has been generated automatically remove the reference to this block ID.`, }), ); // 2. fixPatches (produce patches for optional application later) template.blockIds.splice(bIndex, 1); }); // patternState.patterns[] => pattern.templates[] => demoIds (template.demoIds || []).forEach((demoId, dIndex) => { if (demoIds.includes(demoId)) return; // 1. Error errors.push( new DoctorError({ name: 'DEMO_ID_NOT_FOUND', message: `Demo ID [${demoId}] referenced within pattern [${pattern.id}] template [${template.id}] not found in db.demos.byId. A fix patch has been generated automatically remove the reference to this demo ID.`, }), ); // 2. fixPatches template.demoIds.splice(dIndex, 1); }); }); // patternState.patterns => pattern.subPages[] (pattern.subPages || []).forEach((subPage) => { // patternState.patterns => pattern.subPages[] => blockIds (subPage.blockIds || []).forEach((blockId, bIndex) => { if (blockIds.includes(blockId)) return; // 1. Error errors.push( new DoctorError({ name: 'BLOCK_ID_NOT_FOUND', message: `Block ID [${blockId}] referenced within pattern [${pattern.id}] subPage [${subPage.id}] not found in db.blocks.byId. A fix patch has been generated automatically remove the reference to this block ID.`, }), ); // 2. fixPatches subPage.blockIds.splice(bIndex, 1); }); }); }); // Pages const pageIds = Object.entries(draft.customPagesState?.pages || {}).reduce( (acc, [key, obj]) => { if (key !== obj.id) { errors.push( new DoctorError({ name: 'KEY_ID_MISMATCH', message: `Custom page ID [${obj.id}] does not match key [${key}] in db.customPagesState.pages. A fix patch has been generated automatically to remove the custom page item.`, }), ); delete draft.customPagesState.pages[key]; return acc; } acc.push(key); return acc; }, [], ); // customPageState.pages Object.values(draft?.customPagesState?.pages || []).forEach((page) => { // customPageState.pages => blockIds page.blockIds.forEach((blockId, bIndex) => { if (blockIds.includes(blockId)) return; // 1. Error errors.push( new DoctorError({ name: 'BLOCK_ID_NOT_FOUND', message: `Block ID [${blockId}] referenced within custom page [${page.id}] not found in db.blocks.byId. A fix patch has been generated automatically remove the reference to this block ID.`, }), ); // 2. fixPatches page.blockIds.splice(bIndex, 1); }); }); // Navs type NavItem = (typeof draft.navsState.byId)[string]; /** * When removing a nav item, we need to remove it from byId AND order */ const removeNavItem = (navId: string) => { delete draft.navsState.byId[navId]; // Nuke from byId const navPos = draft.navsState.order.indexOf(navId); // Only remove from order if it's there navPos > -1 && draft.navsState.order.splice(navPos, 1); // Nuke from order }; /** * When adding, leave order alone, it's already in the right spot */ const addNavItem = (nav: NavItem) => { draft.navsState.byId[nav.id] = nav; const navPos = draft.navsState.order.indexOf(nav.id); // Only add to order if it's not already there navPos < 0 && draft.navsState.order.push(nav.id); }; /** * Updating one nav item for another, ensure order is updated too with new ID */ const updateNavItem = (originalNav: NavItem, newNav: NavItem) => { delete draft.navsState.byId[originalNav.id]; draft.navsState.byId[newNav.id] = newNav; const originalPos = draft.navsState.order.indexOf(originalNav.id); originalPos > -1 && draft.navsState.order.splice(originalPos, 1, newNav.id); }; /** * Quick audit of navs to make sure they're clean */ Object.entries(draft.navsState?.byId || {}).forEach(([key, obj]) => { // Use this chance to check that key and obj.id match, throw if not if (key !== obj.id) { errors.push( new DoctorError({ name: 'KEY_ID_MISMATCH', message: `Nav ID [${obj.id}] does not match key [${key}] in db.navs.byId. A fix patch has been generated automatically to remove the nav item.`, }), ); removeNavItem(key); } }); const makeNavIds = (): string[] => Object.keys(draft.navsState?.byId || {}); // Quick lookup of all nav IDs, re-set every time a nav is removed let navIds = makeNavIds(); // Ensure navIds is updated after every nav removal const deleteAndMakeNavIds = flow(removeNavItem, makeNavIds); // const addAndMakeNavIds = flow(addNavItem, makeNavIds); // Not used yet // navsState.navs - check that all nav IDs reference real page or pattern Object.values(draft?.navsState?.byId || {}).forEach((nav) => { // Can be root with no path, e.g. click "Assets" in main nav of Toby if (nav.parentId === 'root' && (nav.path === '' || nav.path === '/')) return; // Empty root ID, should always be at least "root" if (nav.parentId === '' || nav.parentId === undefined) { errors.push( new DoctorError({ name: 'NAV_ORPHANED_NO_PARENT', message: `Nav ID [${nav.id}] has no parent ID. A fix patch has been generated automatically to set the parent ID to 'root' to surface the nav item for manual deletion.`, }), ); nav.parentId = 'root'; } // When parentId is not empty (or 'root'), cannot find parent Id. Therefore // set to 'root' to surface for manual deletion if ( nav.parentId !== 'root' && !!nav.parentId && !navIds.find((navId) => navId === nav.parentId) ) { errors.push( new DoctorError({ name: 'NAV_ORPHANED_NO_PARENT', message: `Nav ID [${nav.id}] has a parent ID [${nav.parentId}] that does not exist. A fix patch has been generated automatically to set the parent ID to 'root' to surface the nav item for manual deletion.`, }), ); nav.parentId = 'root'; } // Determine if the path is a fully qualified URL. We expect most paths // to not pass this check, since most are local (ie /thing/other-thing) if (nav.path && isRemoteUrl(nav.path)) return; // Does the path start with a `/`? If not, let them know and fix if (nav.path && !nav.path.startsWith('/')) { errors.push( new DoctorError({ name: 'NAV_PATH_BAD_FORMAT', message: `Nav ID [${nav.id}] has an internal path [${nav.path}] that does not start with a slash ("/"). A fix patch has been generated automatically to add a slash to the beginning of the path.`, }), ); nav.path = `/${nav.path}`; } // Garbage nav IDs like `/page/colors` made it into workspace starter years ago. // This led to errors, so we need to turn these into proper nav IDs like `colors` const [idEntity, idEntityId] = extractPathParts(nav.id) || []; // These "slash IDs" need to be considered further down const isSlashId = !!(idEntity && idEntityId); if ( // It's a page, and the page exists (idEntity === 'page' && pageIds.find((pageId) => pageId === idEntityId)) || // It's a pattern, and the pattern exists (idEntity === 'pattern' && patternIds.find((patternId) => patternId === idEntityId)) ) { errors.push( new DoctorError({ name: 'NAV_ID_BAD_FORMAT', message: `Nav ID [${nav.id}] is not proper ID format. A fix patch has been generated automatically to set the nav ID to [${idEntityId}].`, }), ); updateNavItem(nav, { ...nav, id: idEntityId, }); } const [pathEntity, pathEntityId] = extractPathParts(nav.path); // Every single PAGE or PATTERN NAV item ID should be **exactly the same** // as the PAGE ID OR PATTERN ID it links to. So if a customPage has the id // 'custom-page' there will be nav with ID of `custom-page'. Yes, this is weird. // So we have to ensure that the path fragment after /page/ or /pattern/ is // the same as the ID of the page or pattern. So we have to enforce the following: // // 1. A nav with id 'colors' must have a path of '/page/colors' OR '/pattern/colors' // 2. A nav with id 'colors' and a path of '/page/colors' must have a corresponding page with id 'colors' // 3. A nav with id 'colors' and a path of '/pattern/colors' must have a corresponding pattern with id 'colors' // 1. A nav with id 'colors' must have a path of '/page/colors' OR '/pattern/colors' if ( // Skip in the case of e.g. /page/colors ID format !isSlashId && // But definitely check if the ID does not align with the PATH entityId nav.id !== pathEntityId ) { errors.push( new DoctorError({ name: 'NAV_ID_BAD_FORMAT', message: `Nav IDs must match the page or pattern they link to. The Nav ID [${nav.id}] does not match its path's [${nav.path}] entity ID [${pathEntityId}]. A fix patch has been generated automatically to set the nav ID to [${pathEntityId}].`, }), ); updateNavItem(nav, { ...nav, id: pathEntityId, }); } // 2. Can't find pageId referenced by nav path if ( pathEntity === 'page' && !pageIds.find((pageId) => pageId === pathEntityId) ) { errors.push( new DoctorError({ name: 'NAV_ITEM_PAGE_MISSING', message: `Nav ID [${nav.id}] references a page ID [${pathEntityId}] that does not exist. A fix patch has been generated automatically to remove the nav item.`, }), ); navIds = deleteAndMakeNavIds(nav.id); } // 3. Can't find patternId referenced by nav path if ( pathEntity === 'pattern' && !patternIds.find((patternId) => patternId === pathEntityId) ) { errors.push( new DoctorError({ name: 'NAV_ITEM_PAGE_MISSING', message: `Nav ID [${nav.id}] references a pattern ID [${pathEntityId}] that does not exist. A fix patch has been generated automatically to remove the nav item.`, }), ); navIds = deleteAndMakeNavIds(nav.id); } }); // Finally, ensure that all items in navState.order actually exist in byId }); return { data, errors, fixPatches, }; };