import { looseEqual, isDraft, type Draft, isEmpty } from '@knapsack/utils'; import type { KsAppClientDataNoMeta } from '@knapsack/types'; /** * Ensure that the blockIds are valid and exist in the blocks byId */ function ensureHealthBlockIds({ blockIdsParent, appClientData: { db: { blocks: { byId }, }, }, }: { blockIdsParent: Draft<{ blockIds: string[]; }>; appClientData: KsAppClientDataNoMeta; }): void { if (!blockIdsParent.blockIds) { blockIdsParent.blockIds = []; return; } const newBlockIds = // remove duplicates [...new Set(blockIdsParent.blockIds)] // filter out any ids that are not in the blocks byId .filter((id) => { const hasBlock = !!byId[id]; if (!hasBlock) { console.error(`Block with id "${id}" does not exist, removing.`); } return hasBlock; }); // if they are equal, we don't need to update b/c we don't want to make a patch if we don't need to if (!looseEqual(newBlockIds, blockIdsParent.blockIds)) { blockIdsParent.blockIds = newBlockIds; } } /** * Finds all demos that are dependent on the given demo. * The passed in `demoIds: Set` will contain all results after this function is called. * Also will delete any slotted demos that do not exist anymore. * Doing it this way to minimize the execution time. * @example * ```ts * const demoIds = new Set(); * getDependentDemoIds({ appClientData, demoId: '123', demoIds }); * console.log(demoIds); * ``` */ function getDependentDemoIds({ appClientData, demoId, demoIds, }: { appClientData: Draft; demoId: string; demoIds: Set; }): void { const { patterns } = appClientData.patternsState; const demosById = appClientData.db.demos.byId; const demo = demosById[demoId]; if (!demo) { return; } switch (demo.type) { case 'template': return; case 'data-w-template-info': case 'data': { if (!demo.data.slots) return; for (const [slotName, slotItems] of Object.entries(demo.data.slots)) { // remove any items that are not in the demosById // we remove them AFTER looping through them, best not to alter an array while looping through it const slotItemsToRemove: Array<(typeof slotItems)[number]> = []; for (const slotItem of slotItems) { switch (slotItem.type) { case 'text': break; case 'template-reference': { const { patternId, templateId } = slotItem; const pattern = patterns[patternId]; const template = pattern?.templates?.find?.( (t) => t.id === templateId, ); if (!template) { slotItemsToRemove.push(slotItem); } break; } case 'template-demo': { if ( // prevent circular dependency slotItem.demoId === demoId || // prevent re-work demoIds.has(slotItem.demoId) ) { break; } const slotDemo = demosById[slotItem.demoId]; if (slotDemo) { if (slotDemo.type === 'data') { const { patternId, templateId } = slotDemo; const pattern = patterns[patternId]; const template = pattern?.templates?.find?.( (t) => t.id === templateId, ); if (!template) { slotItemsToRemove.push(slotItem); break; } } demoIds.add(slotDemo.id); getDependentDemoIds({ appClientData, demoId: slotItem.demoId, demoIds, }); } else { // demo no longer exists, let's remove the slot reference slotItemsToRemove.push(slotItem); } break; } default: { const _exhaustiveCheck: never = slotItem; } } } if (slotItemsToRemove.length > 0) { demo.data.slots[slotName] = demo.data.slots[slotName].filter( (item) => { switch (item.type) { case 'text': return true; case 'template-demo': { return !slotItemsToRemove.some( (i) => i.type === 'template-demo' && i.demoId === item.demoId, ); } case 'template-reference': { return !slotItemsToRemove.some( (i) => i.type === 'template-reference' && i.patternId === item.patternId && i.templateId === item.templateId, ); } default: { const _exhaustiveCheck: never = item; return true; } } }, ); } } return; } default: { const _exhaustiveCheck: never = demo; } } } function ensureHealthyNavsState({ appClientData, }: { appClientData: Draft; }): void { const navItems = appClientData.navsState.byId; const cleanNavItems: typeof navItems = {}; for (const [key, navItem] of Object.entries(navItems)) { // handle groups (no path) and external nav items if (isEmpty(navItem.path) || navItem.path.startsWith('http')) { cleanNavItems[key] = navItem; continue; } // skip items that dont return a valid id from the path const [ _relativeSlash, // path starts with / so need to handle it /** @type {'pages' | 'pattern'} */ pathType, pathId, ] = navItem.path.split('/'); if (!pathId) { cleanNavItems[key] = navItem; continue; } // ensure the pathId references an existing entity if ( (pathType === 'pages' && !appClientData.customPagesState?.pages?.[pathId]) || (pathType === 'pattern' && !appClientData.patternsState?.patterns?.[pathId]) ) { // delete the nav item from navsState.byId delete cleanNavItems[key]; continue; } // handle internal nav items if (pathId !== key || pathId !== navItem.id) { // clean up the nav item cleanNavItems[pathId] = { ...navItem, id: pathId, }; // make sure any children are updated const children = Object.values(appClientData.navsState.byId).filter( (child) => child.parentId === key, ); for (const child of children) { child.parentId = pathId; } // replace the old item in order const orderIndex = appClientData.navsState.order.indexOf(key); if (orderIndex !== -1) { appClientData.navsState.order[orderIndex] = pathId; } } else { cleanNavItems[key] = navItem; } } // if they are equal, we don't need to update b/c we don't want to make a patch if we don't need to if (!looseEqual(cleanNavItems, navItems)) { appClientData.navsState.byId = cleanNavItems; } // these IDs are in `byId` but not in `order`, we'll add them to end of order const orphanedIds = Object.keys(appClientData.navsState.byId).filter( (id) => !appClientData.navsState.order.includes(id), ); const newOrder = [ // this should be unique, but we're being safe by making it unique ...new Set([ // exclude any IDs that are in `order` but not in `byId` ...appClientData.navsState.order.filter( (id) => appClientData.navsState.byId[id], ), // add any IDs that are in `byId` but not in `order` ...orphanedIds, ]), ]; if (!looseEqual(newOrder, appClientData.navsState.order)) { appClientData.navsState.order = newOrder; } } /** * Keep this function fast. It sits in critical path of UI load. * Minimize repeated work (especially in nested loops). */ export function fixAppClientData({ appClientData, }: { appClientData: Draft; }) { if (!isDraft(appClientData)) { throw new Error('Data must be a draft'); } const usedDemoIds = new Set(); for (const pattern of Object.values( appClientData.patternsState?.patterns || {}, )) { // filter out any null values const tabs = pattern.tabs?.filter(Boolean) || []; // ensure all tabs are unique const uniqueTabs = tabs.filter( (tab, index) => index === tabs.findIndex((t) => t.id === tab.id), ); // ensuring all tabs reference existing content, removing any that don't pattern.tabs = uniqueTabs.filter((tab) => { if (!tab) return false; switch (tab.type) { case 'subPage': return pattern.subPages.some((s) => s.id === tab.id); case 'template': return pattern.templates.some((t) => t.id === tab.id); case 'designSrc': return !!pattern.designSrcComponentsById[tab.id]; default: { const _exhaustiveCheck: never = tab.type; return false; } } }); if (pattern.subPages) { for (const subPage of pattern.subPages) { ensureHealthBlockIds({ appClientData, blockIdsParent: subPage, }); } } if (pattern.templates) { for (const template of pattern.templates) { if (template.demoIds) { for (const demoId of template.demoIds) { usedDemoIds.add(demoId); getDependentDemoIds({ appClientData, demoId, demoIds: usedDemoIds, }); const demo = appClientData.db.demos.byId[demoId]; if (demo?.type === 'data') { if (!demo.patternId || !demo.templateId) { demo.patternId = pattern.id; demo.templateId = template.id; } } } } ensureHealthBlockIds({ blockIdsParent: template, appClientData, }); } } } for (const page of Object.values( appClientData.customPagesState?.pages || {}, )) { ensureHealthBlockIds({ blockIdsParent: page, appClientData, }); } // clean up navs ensureHealthyNavsState({ appClientData }); for (const id of Object.keys(appClientData.db.demos.byId)) { if (!usedDemoIds.has(id)) { delete appClientData.db.demos.byId[id]; } } }