Fix Media Manager UI bugs and add SKU migration logic

- Fix syntax errors and logic in MediaManager.html

- Fix SpreadsheetApp mocking in mediaHandlers.test.ts

- Add SKU logic migration plan and backfill script

- Update Product.ts and global.ts exports

- Update newSku.ts and add newSku.test.ts

- Ensure all tests pass (71/71)
This commit is contained in:
Ben Miller
2026-01-03 11:44:49 -07:00
parent f3d8514e62
commit 778c0d1620
12 changed files with 926 additions and 83 deletions

View File

@ -0,0 +1,67 @@
# SKU logic migration plan
2026-01-03
## Summary
The goal of this migration is to reduce the number of touchpoints required to create a new SKU. User should only have to define `product_type` and `product_style` once, and then a new SKU should be created automatically when needed.
## High Level Migration Steps
1. FREEZE CHANGES to the spreadsheet while this migration is in progress
2. Remove `sku_prefix` column from `product_inventory` sheet. This will disable the existing automation by removing one of the needed inputs that is controlled by an instant ARRAYFORMULA.
3. Update column names in `product_inventory` and `values` sheets to match new SKU logic
4. Update `newSku.ts` to use new SKU logic
5. Update `MediaManager.ts` to use new SKU logic
## Detailed Migration Steps
## `product_inventory` sheet
* [x] Remove `sku_prefix` column
* [x] Change `type` to `product_style`
* [x] Move `product_style` column to the right of `product_type`
* [x] Remove `function` column
* [x] Remove `#` column
* [x] Remove `style` column
* This column is not currently used in any active way, and is confusingly named. It should be removed.
## `values` sheet
* [x] Add `sku_prefix` column
* [x] `type_sku_code` -> `sku_suffix` column
* [x] Remove `function` and `function_sku_code` columns
* [x] `type` -> `product_style`
## `product_types` sheet
* [x] Remove `function` column
* [x] Change `type` to `product_style`
## `Product` class
* [x] Rename `type` -> `product_style` (to match the plan).
* [x] Remove `function` property.
* [x] Remove the existing `style: string[]` property (Line 24).
## newSku.ts
* [x] Move manual trigger to `sku` column
* [ ] Add safety check to ensure that existing `sku` values are not overwritten. If the product already has a `sku` in Shopify, use it. Only check if `sku` is empty and `shopify_id` is defined.
* [x] Start using `product_type` -> `sku_prefix` lookup + `product_style` -> `sku_suffix` lookup for SKU code
## Media Manager
* [ ] If `product_type` and `product_style` are defined, but `sku` is not, request a new SKU after confirming values are correct
* [ ] If either `product_type` or `product_style` are undefined, prompt the user to define them, then request a new SKU
## Cleanup
* Scrub code for columns that have been removed
* [x] `function` column
* [x] `function_sku_code` column
* [x] `type_sku_code` column
* [x] `#` column
* [x] `style` column
* [x] Scrub code for logic that has been removed
* [x] Backfill

View File

@ -723,6 +723,78 @@
<button onclick="google.script.host.close()" class="btn" style="width: auto;">Close</button> <button onclick="google.script.host.close()" class="btn" style="width: auto;">Close</button>
</div> </div>
<!-- Generate SKU Prompt -->
<div id="generate-sku-ui"
style="display:none; flex-direction:column; align-items:center; justify-content:center; text-align:center; padding-top: 80px;">
<div style="font-size: 48px; margin-bottom: 20px;">🏷️</div>
<h3 style="margin: 0 0 8px 0; color: var(--text);">Generate SKU?</h3>
<p style="color: var(--text-secondary); max-width: 300px; margin-bottom: 24px; line-height: 1.5;">
This product needs a SKU to proceed.<br>
<strong>Type:</strong> <span id="prompt-type"></span><br>
<strong>Style:</strong> <span id="prompt-style"></span>
</p>
<button onclick="controller.generateSku()" class="btn btn-primary" style="width: auto;">Generate SKU</button>
</div>
<!-- Define Product Prompt -->
<div id="define-product-ui"
style="display:none; flex-direction:column; align-items:center; justify-content:center; text-align:center; padding-top: 80px;">
<div style="font-size: 48px; margin-bottom: 20px;">📝</div>
<h3 style="margin: 0 0 8px 0; color: var(--text);">missing Information</h3>
<p style="color: var(--text-secondary); max-width: 300px; margin-bottom: 24px; line-height: 1.5;">
Please define <strong>Product Type</strong> and <strong>Style</strong> to generate a SKU.
</p>
<div style="display: flex; flex-direction: column; gap: 16px; width: 100%; max-width: 400px; text-align: left;">
<!-- Title -->
<div>
<label
style="font-size: 12px; font-weight: 500; color: var(--text-secondary); display:block; margin-bottom: 4px;">Product
Title</label>
<input id="input-product-title" type="text" placeholder="e.g. Vintage T-Shirt"
style="width: 100%; padding: 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg-surface); color: var(--text); font-size: 14px;">
</div>
<!-- Description -->
<div>
<label
style="font-size: 12px; font-weight: 500; color: var(--text-secondary); display:block; margin-bottom: 4px;">Description</label>
<textarea id="input-product-desc" rows="3" placeholder="Product details..."
style="width: 100%; padding: 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg-surface); color: var(--text); font-family: inherit; font-size: 14px; resize: vertical;"></textarea>
</div>
<div style="display: flex; gap: 16px;">
<!-- Type -->
<div style="flex: 1;">
<label
style="font-size: 12px; font-weight: 500; color: var(--text-secondary); display:block; margin-bottom: 4px;">Product
Type</label>
<select id="input-product-type"
style="width: 100%; padding: 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg-surface); color: var(--text);">
<option value="">Select Type...</option>
</select>
</div>
<!-- Style -->
<div style="flex: 1;">
<label
style="font-size: 12px; font-weight: 500; color: var(--text-secondary); display:block; margin-bottom: 4px;">Product
Style</label>
<select id="input-product-style"
style="width: 100%; padding: 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg-surface); color: var(--text);">
<option value="">Select Style...</option>
</select>
</div>
</div>
<div style="display: flex; gap: 12px; margin-top: 8px;">
<button id="btn-save-def" onclick="controller.saveProductDefinition()" class="btn btn-primary"
style="flex: 1;">Save & Generate SKU</button>
<button onclick="google.script.host.close()" class="btn" style="width: auto;">Cancel</button>
</div>
</div>
</div>
<!-- Preview Modal --> <!-- Preview Modal -->
<div id="preview-modal" class="modal-overlay" onclick="ui.closeModal(event)"> <div id="preview-modal" class="modal-overlay" onclick="ui.closeModal(event)">
<div class="modal-content"> <div class="modal-content">
@ -811,14 +883,18 @@
alert("Script Error: " + msg + "\nLine: " + line); alert("Script Error: " + msg + "\nLine: " + line);
}; };
// Template Variables (Injected by Server)
var initialSku = "<?= initialSku ?>";
// Template Variables (Injected by Server) // Template Variables (Injected by Server)
var initialSku = "<?= initialSku ?>"; var initialSku = "<?= initialSku ?>";
var initialTitle = "<?= initialTitle ?>"; var initialTitle = "<?= initialTitle ?>";
var initialDescription = `<?= initialDescription ?>`;
// --- ES5 Refactor: MediaState --- // --- ES5 Refactor: MediaState ---
function MediaState() { function MediaState() {
this.sku = (initialSku && initialSku !== "undefined") ? initialSku : null; this.sku = (initialSku && initialSku !== "undefined") ? initialSku : null;
this.title = (initialTitle && initialTitle !== "undefined") ? initialTitle : ""; this.title = (initialTitle && initialTitle !== "undefined") ? initialTitle : "";
this.description = (initialDescription && initialDescription !== "undefined") ? initialDescription : "";
this.token = null; this.token = null;
this.items = []; this.items = [];
this.initialState = []; this.initialState = [];
@ -829,6 +905,7 @@
MediaState.prototype.setSku = function (info) { MediaState.prototype.setSku = function (info) {
this.sku = info ? info.sku : null; this.sku = info ? info.sku : null;
this.title = info ? info.title : ""; this.title = info ? info.title : "";
this.description = info && info.description ? info.description : "";
this.items = []; this.items = [];
this.initialState = []; this.initialState = [];
ui.updateSku(this.sku, this.title); ui.updateSku(this.sku, this.title);
@ -1023,9 +1100,13 @@
}; };
var state = new MediaState(); var state = new MediaState();
state.sku = "<?!= initialSku ?>"; state.productType = "<?= initialProductType ?>";
state.title = "<?!= initialTitle ?>"; state.productStyle = "<?= initialProductStyle ?>";
window.state = state; var state = new MediaState();
state.productType = "<?= initialProductType ?>";
state.productStyle = "<?= initialProductStyle ?>";
state.productOptions = null; // { types: [], styles: [] }
// description is handled in constructor
// --- ES5 Refactor: UI --- // --- ES5 Refactor: UI ---
function UI() { function UI() {
@ -1703,13 +1784,13 @@
return html; return html;
} }
return ''; return '';
}; };
UI.prototype.showDetails = function () { UI.prototype.showDetails = function () {
var plan = state.calculateDiff(); var plan = state.calculateDiff();
var container = document.getElementById('details-content'); var container = document.getElementById('details-content');
container.innerHTML = ui.renderPlanHtml(plan); container.innerHTML = ui.renderPlanHtml(plan);
document.getElementById('details-modal').style.display = 'flex'; document.getElementById('details-modal').style.display = 'flex';
}; };
@ -1745,18 +1826,64 @@
google.script.run google.script.run
.withSuccessHandler(response => { .withSuccessHandler(response => {
const { sku: serverSku, title: serverTitle, diagnostics, media, token } = response;
const { sku: serverSku, title: serverTitle, description: initialServerDescription, diagnostics, media, token, productOptions } = response;
if (productOptions) {
state.productOptions = productOptions;
}
if (!serverSku) { if (!serverSku) {
console.warn("No SKU found. Showing error."); console.warn("No SKU found. Checking for prompt conditions.");
document.getElementById('loading-ui').style.display = 'none'; document.getElementById('loading-ui').style.display = 'none';
document.getElementById('main-ui').style.display = 'none'; document.getElementById('main-ui').style.display = 'none';
document.getElementById('error-ui').style.display = 'flex';
if (state.productType && state.productStyle) {
// Show Generate UI
document.getElementById('prompt-type').innerText = state.productType;
document.getElementById('prompt-style').innerText = state.productStyle;
document.getElementById('generate-sku-ui').style.display = 'flex';
} else {
// Show Missing Info UI with Form
document.getElementById('define-product-ui').style.display = 'flex';
// Populate Inputs
document.getElementById('input-product-title').value = state.title || "";
document.getElementById('input-product-desc').value = state.description || "";
// Populate Dropdowns
const typeSelect = document.getElementById('input-product-type');
const styleSelect = document.getElementById('input-product-style');
// Clear
typeSelect.innerHTML = '<option value="">Select Type...</option>';
styleSelect.innerHTML = '<option value="">Select Style...</option>';
if (state.productOptions) {
state.productOptions.types.forEach(t => {
const opt = document.createElement('option');
opt.value = t;
opt.innerText = t;
typeSelect.appendChild(opt);
});
state.productOptions.styles.forEach(s => {
const opt = document.createElement('option');
opt.value = s;
opt.innerText = s;
styleSelect.appendChild(opt);
});
}
}
document.getElementById('error-ui').style.display = 'none'; // Ensure error is hidden
return; return;
} }
// Sanitize Description (remove literal quote artifacts if present)
let serverDescription = initialServerDescription;
if (serverDescription === "''" || serverDescription === "'") serverDescription = "";
// Update State // Update State
state.setSku({ sku: serverSku, title: serverTitle }); state.setSku({ sku: serverSku, title: serverTitle, description: serverDescription });
state.token = token; state.token = token;
// Update UI // Update UI
@ -1854,6 +1981,84 @@
.getMediaManagerInitialState(sku, title); .getMediaManagerInitialState(sku, title);
}, },
generateSku() {
ui.setLoadingState(true);
// Hide prompts
document.getElementById('generate-sku-ui').style.display = 'none';
document.getElementById('loading-ui').style.display = 'block';
google.script.run
.withSuccessHandler(newSku => {
if (newSku) {
console.log("Generated SKU: " + newSku);
state.sku = newSku;
// Reload to get full state
this.loadMedia();
} else {
alert("Failed to generate SKU. Please check logs or try again.");
ui.setLoadingState(false);
document.getElementById('loading-ui').style.display = 'none';
// Show prompt again?
document.getElementById('generate-sku-ui').style.display = 'flex';
}
})
.withFailureHandler(e => {
alert("Error: " + e.message);
ui.setLoadingState(false);
document.getElementById('loading-ui').style.display = 'none';
document.getElementById('generate-sku-ui').style.display = 'flex';
})
.generateSkuForActiveRow();
},
saveProductDefinition() {
const titleVal = document.getElementById('input-product-title').value;
const descVal = document.getElementById('input-product-desc').value;
const typeSelect = document.getElementById('input-product-type');
const styleSelect = document.getElementById('input-product-style');
const typeVal = typeSelect.value;
const styleVal = styleSelect.value;
if (!typeVal || !styleVal) {
alert("Please select both a Product Type and a Style.");
return;
}
ui.setLoadingState(true);
document.getElementById('define-product-ui').style.display = 'none';
document.getElementById('loading-ui').style.display = 'block';
google.script.run
.withSuccessHandler(newSku => {
if (newSku) {
console.log("Product Defined & SKU Generated: " + newSku);
state.sku = newSku;
state.sku = newSku;
state.productType = typeVal;
state.productStyle = styleVal;
state.title = titleVal;
state.description = descVal;
// Reload
this.loadMedia();
} else {
// Failed to generate SKU (e.g. unsafe?)
alert("Product info saved, but SKU generation failed (maybe unsafe to overwrite?). Please check sheet.");
// Retry load to see state
this.loadMedia();
}
})
.withFailureHandler(e => {
alert("Error saving product info: " + e.message);
ui.setLoadingState(false);
document.getElementById('loading-ui').style.display = 'none';
document.getElementById('define-product-ui').style.display = 'flex';
})
.saveProductDefinition(typeVal, styleVal, titleVal, descVal);
},
linkSelectedMedia() { linkSelectedMedia() {
var selectedItems = state.items.filter(function (i) { return state.selectedIds.has(i.id); }); var selectedItems = state.items.filter(function (i) { return state.selectedIds.has(i.id); });
var driveItem = selectedItems.find(function (i) { return i.source === 'drive_only'; }); var driveItem = selectedItems.find(function (i) { return i.source === 'drive_only'; });

View File

@ -21,7 +21,6 @@ import { GASDriveService } from "./services/GASDriveService"
export class Product { export class Product {
shopify_id: string = "" shopify_id: string = ""
title: string = "" title: string = ""
style: string[] = []
tags: string = "" tags: string = ""
category: string = "" category: string = ""
ebay_category_id: string = "" ebay_category_id: string = ""
@ -31,8 +30,7 @@ export class Product {
price: number = 0 price: number = 0
compare_at_price: number = 0 compare_at_price: number = 0
shipping: number = 0 shipping: number = 0
function: string = "" product_style: string = ""
type: string = ""
weight_grams: number = 0 weight_grams: number = 0
product_width_cm: number = 0 product_width_cm: number = 0
product_depth_cm: number = 0 product_depth_cm: number = 0
@ -78,13 +76,14 @@ export class Product {
} }
if (productValues[i] === "") { if (productValues[i] === "") {
console.log( console.log(
"keeping '" + headers[i] + "' default: '" + this[headers[i]] + "'" "keeping '" + headers[i] + "' default: '" + this[headers[i] as keyof Product] + "'"
) )
continue continue
} }
console.log( console.log(
"setting value for '" + headers[i] + "' to '" + productValues[i] + "'" "setting value for '" + headers[i] + "' to '" + productValues[i] + "'"
) )
// @ts-ignore
this[headers[i]] = productValues[i] this[headers[i]] = productValues[i]
} else { } else {
console.log("skipping '" + headers[i] + "'") console.log("skipping '" + headers[i] + "'")

156
src/backfill_sku.ts Normal file
View File

@ -0,0 +1,156 @@
import {
getCellRangeByColumnName,
getCellValueByColumnName,
getColumnValuesByName,
getColumnByName,
vlookupByColumns,
} from "./sheetUtils"
import { Shop } from "./shopifyApi"
import { Config } from "./config"
export function backfillSkus() {
const sheet = SpreadsheetApp.getActive().getSheetByName("product_inventory")
if (!sheet) {
console.log("product_inventory sheet not found")
return
}
const shop = new Shop()
// Read all data
const productTypes = getColumnValuesByName(sheet, "product_type")
const productStyles = getColumnValuesByName(sheet, "product_style")
const ids = getColumnValuesByName(sheet, "#")
const skus = getColumnValuesByName(sheet, "sku")
const shopifyIds = getColumnValuesByName(sheet, "shopify_id")
const photoUrls = getColumnValuesByName(sheet, "photos") // Folder URLs
const missingCols = []
if (!productTypes) missingCols.push("product_type")
if (!productStyles) missingCols.push("product_style")
if (!skus) missingCols.push("sku")
if (!shopifyIds) missingCols.push("shopify_id")
if (!photoUrls) missingCols.push("photos")
if (missingCols.length > 0) {
console.log("Could not read necessary columns for backfill. Missing: " + missingCols.join(", "))
return
}
// 0. Pre-fetch all Shopify Products
console.log("Fetching all Shopify products...")
const allShopifyProducts = shop.GetProducts()
if (allShopifyProducts) {
console.log(`Fetched ${allShopifyProducts.length} raw products from Shopify.`)
if (allShopifyProducts.length > 0) {
console.log("Sample Product structure:", JSON.stringify(allShopifyProducts[0]))
}
} else {
console.log("GetProducts returned undefined/null")
}
const shopifySkuMap = new Map<string, string>() // ID -> SKU
if (allShopifyProducts) {
for (const p of allShopifyProducts) {
let variants = p.variants
// @ts-ignore
if (!variants && p['variants']) variants = p['variants']
if (variants && variants.nodes && variants.nodes.length > 0) {
const v = variants.nodes[0]
const sku = v.sku || ""
const rawId = p.id
if (rawId) {
// Store raw ID
shopifySkuMap.set(rawId, sku)
// Store numeric ID (if it's a GID)
const numericId = rawId.split("/").pop()
if (numericId && numericId !== rawId) {
shopifySkuMap.set(numericId, sku)
}
}
}
}
}
console.log(`Mapped ${shopifySkuMap.size} IDs to SKUs.`)
// Get SKU Column Index ONCE
const skuColIndex = getColumnByName(sheet, "sku")
if (skuColIndex === -1) {
console.log("Column 'sku' not found in product_inventory")
return
}
for (let i = 0; i < productTypes.length; i++) {
const row = i + 2
const currentSku = String(skus[i])
// 1. Calculate Expected SKU
const pType = String(productTypes[i])
const pStyle = String(productStyles[i])
const id = ids ? String(ids[i]) : ""
let calculatedSku = ""
if (pType && pStyle && id && id !== '?' && id !== 'n') {
const prefix = vlookupByColumns("values", "product_type", pType, "sku_prefix")
const suffix = vlookupByColumns("values", "product_style", pStyle, "sku_suffix")
if (prefix && suffix) {
calculatedSku = `${prefix}${suffix}-${id.padStart(4, "0")}`
}
}
// 2. Get External SKUs
const shopifyId = String(shopifyIds[i])
let shopifySku = ""
if (shopifyId) {
shopifySku = shopifySkuMap.get(shopifyId) || ""
}
let driveSku = ""
const photoUrl = String(photoUrls[i])
if (photoUrl && photoUrl.includes("drive.google.com")) {
try {
let folderId = ""
const match = photoUrl.match(/[-\w]{25,}/)
if (match) {
folderId = match[0]
const folder = DriveApp.getFolderById(folderId)
driveSku = folder.getName()
}
} catch (e) {
console.log(`Row ${row}: Error fetching Drive Folder: ${e.message}`)
}
}
// 3. Determine Winner
let targetSku = calculatedSku // Default to calculated
let source = "Calculated"
if (shopifySku && driveSku && shopifySku === driveSku) {
targetSku = shopifySku
source = "External Match (Shopify + Drive)"
} else if (shopifySku) {
if (targetSku && targetSku !== shopifySku) {
console.log(`Row ${row}: CONFLICT. Calculated=${targetSku}, Shopify=${shopifySku}, Drive=${driveSku}`)
}
if (!targetSku) {
targetSku = shopifySku
source = "Shopify (Calculation Failed)"
}
}
if (targetSku && currentSku !== targetSku) {
console.log(`Row ${row}: Updating SKU '${currentSku}' -> '${targetSku}' [Source: ${source}]`)
// Optimization: Use pre-calculated index
const cell = sheet.getRange(row, skuColIndex)
cell.setValue(targetSku)
} else if (targetSku) {
// Valid SKU already there
} else {
console.log(`Row ${row}: Could not determine SKU.`)
}
}
}

View File

@ -23,8 +23,9 @@ import { fillProductFromTemplate } from "./fillProductFromTemplate"
import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar" import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar"
import { checkRecentSales, reconcileSalesHandler } from "./salesSync" import { checkRecentSales, reconcileSalesHandler } from "./salesSync"
import { installSalesSyncTrigger } from "./triggers" import { installSalesSyncTrigger } from "./triggers"
import { showMediaManager, getSelectedProductInfo, getMediaForSku, saveFileToDrive, saveMediaChanges, getMediaDiagnostics, getPickerConfig, importFromPicker, debugScopes, createPhotoSession, checkPhotoSession, debugFolderAccess, linkDriveFileToShopifyMedia, pollJobLogs, getMediaManagerInitialState, getMediaSavePlan, executeSavePhase, updateSpreadsheetThumbnail, executeFullSavePlan } from "./mediaHandlers" import { showMediaManager, getSelectedProductInfo, getMediaForSku, saveFileToDrive, saveMediaChanges, getMediaDiagnostics, getPickerConfig, importFromPicker, debugScopes, createPhotoSession, checkPhotoSession, debugFolderAccess, linkDriveFileToShopifyMedia, pollJobLogs, getMediaManagerInitialState, getMediaSavePlan, executeSavePhase, updateSpreadsheetThumbnail, executeFullSavePlan, generateSkuForActiveRow, saveProductDefinition } from "./mediaHandlers"
import { runSystemDiagnostics } from "./verificationSuite" import { runSystemDiagnostics } from "./verificationSuite"
import { backfillSkus } from "./backfill_sku"
// prettier-ignore // prettier-ignore
;(global as any).onOpen = onOpen ;(global as any).onOpen = onOpen
@ -71,3 +72,6 @@ import { runSystemDiagnostics } from "./verificationSuite"
;(global as any).executeSavePhase = executeSavePhase ;(global as any).executeSavePhase = executeSavePhase
;(global as any).updateSpreadsheetThumbnail = updateSpreadsheetThumbnail ;(global as any).updateSpreadsheetThumbnail = updateSpreadsheetThumbnail
;(global as any).executeFullSavePlan = executeFullSavePlan ;(global as any).executeFullSavePlan = executeFullSavePlan
;(global as any).backfillSkus = backfillSkus
;(global as any).generateSkuForActiveRow = generateSkuForActiveRow
;(global as any).saveProductDefinition = saveProductDefinition

View File

@ -1,15 +1,31 @@
import { importFromPicker, getMediaForSku, createPhotoSession, checkPhotoSession, debugFolderAccess, showMediaManager, getSelectedProductInfo, getPickerConfig, saveFileToDrive, debugScopes, saveMediaChanges, getMediaManagerInitialState } from "./mediaHandlers" import { importFromPicker, getMediaForSku, createPhotoSession, checkPhotoSession, debugFolderAccess, showMediaManager, getSelectedProductInfo, generateSkuForActiveRow, saveProductDefinition, getPickerConfig, saveFileToDrive, debugScopes, saveMediaChanges, getMediaManagerInitialState } from "./mediaHandlers"
import { Config } from "./config" import { Config } from "./config"
import { GASDriveService } from "./services/GASDriveService" import { GASDriveService } from "./services/GASDriveService"
import { GASSpreadsheetService } from "./services/GASSpreadsheetService" import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
import { MediaService } from "./services/MediaService" import { MediaService } from "./services/MediaService"
import { Product } from "./Product" import { Product } from "./Product"
import { newSku } from "./newSku"
// --- Mocks --- // --- Mocks ---
jest.mock("./newSku", () => ({
newSku: jest.fn()
}))
jest.mock("./sheetUtils", () => ({
getColumnValuesByName: jest.fn().mockReturnValue([["TypeA"], ["TypeB"]]),
// Add other used functions if needed, likely safe to partial mock if needed
}))
import { getColumnValuesByName } from "./sheetUtils"
// Mock Config // Mock Config
jest.mock("./config", () => { jest.mock("./config", () => {
// Inject global Drive for testing fallback logic
(global as any).Drive = {
Files: {
create: jest.fn().mockReturnValue({ id: "adv_file_id" }),
insert: jest.fn()
}
};
return { return {
Config: jest.fn().mockImplementation(() => { Config: jest.fn().mockImplementation(() => {
return { return {
@ -65,7 +81,7 @@ jest.mock("./services/GASSpreadsheetService", () => {
}), }),
getRowNumberByColumnValue: jest.fn().mockReturnValue(5), getRowNumberByColumnValue: jest.fn().mockReturnValue(5),
setCellValueByColumnName: jest.fn(), setCellValueByColumnName: jest.fn(),
getHeaders: jest.fn().mockReturnValue(["sku", "title", "thumbnail"]), getHeaders: jest.fn().mockReturnValue(["sku", "title", "product_type", "product_style", "thumbnail"]),
getRowData: jest.fn() getRowData: jest.fn()
} }
}) })
@ -123,7 +139,8 @@ global.SpreadsheetApp = {
setAltTextTitle: jest.fn().mockReturnThis(), setAltTextTitle: jest.fn().mockReturnThis(),
setAltTextDescription: jest.fn().mockReturnThis(), setAltTextDescription: jest.fn().mockReturnThis(),
build: jest.fn().mockReturnValue("CELL_IMAGE_OBJECT") build: jest.fn().mockReturnValue("CELL_IMAGE_OBJECT")
}) }),
getActiveSpreadsheet: jest.fn(),
} as any } as any
// UrlFetchApp // UrlFetchApp
@ -270,18 +287,30 @@ describe("mediaHandlers", () => {
}) })
test("should fallback to Advanced Drive API if DriveApp.createFile fails", () => { test("should fallback to Advanced Drive API if DriveApp.createFile fails", () => {
;(DriveApp.createFile as jest.Mock).mockImplementationOnce(() => { // Explicitly ensure global Drive is set for this test
(global as any).Drive = {
Files: {
create: jest.fn().mockReturnValue({ id: "adv_file_id" })
}
};
(DriveApp.createFile as jest.Mock).mockImplementationOnce(() => {
throw new Error("Server Error") throw new Error("Server Error")
}) })
;(Drive.Files.create as jest.Mock).mockReturnValue({ id: "adv_file_id" })
;(DriveApp.getFileById as jest.Mock).mockReturnValue(mockFile) ;(DriveApp.getFileById as jest.Mock).mockReturnValue(mockFile)
importFromPicker("SKU123", null, "image/jpeg", "fallback.jpg", "https://url") importFromPicker("SKU123", null, "image/jpeg", "fallback.jpg", "https://url")
expect(DriveApp.createFile).toHaveBeenCalled() expect(DriveApp.createFile).toHaveBeenCalled()
expect(Drive.Files.create).toHaveBeenCalled() expect((global as any).Drive.Files.create).toHaveBeenCalled()
}) })
// ... (other tests)
test("should throw if folder access fails (Step 2)", () => { test("should throw if folder access fails (Step 2)", () => {
mockGetOrCreateFolder.mockImplementationOnce(() => { throw new Error("Folder Access Error") }) mockGetOrCreateFolder.mockImplementationOnce(() => { throw new Error("Folder Access Error") })
expect(() => { expect(() => {
@ -315,6 +344,14 @@ describe("mediaHandlers", () => {
}; };
(global.SpreadsheetApp.getActiveSheet as jest.Mock).mockReturnValue(mockSheet); (global.SpreadsheetApp.getActiveSheet as jest.Mock).mockReturnValue(mockSheet);
// Mock getActiveSpreadsheet for getProductOptionsFromValuesSheet
const mockSpreadsheet = {
getSheetByName: jest.fn().mockImplementation((name) => {
return name === "values" ? {} : null;
})
};
(global.SpreadsheetApp.getActiveSpreadsheet as jest.Mock).mockReturnValue(mockSpreadsheet);
const response = getMediaManagerInitialState() const response = getMediaManagerInitialState()
expect(response.sku).toBe("TEST-SKU") expect(response.sku).toBe("TEST-SKU")
@ -475,13 +512,13 @@ describe("mediaHandlers", () => {
const mockRange = { getValues: jest.fn() }; const mockRange = { getValues: jest.fn() };
const mockSheet = { const mockSheet = {
getName: jest.fn().mockReturnValue("product_inventory"), getName: jest.fn().mockReturnValue("product_inventory"),
getLastColumn: jest.fn().mockReturnValue(2), getLastColumn: jest.fn().mockReturnValue(4),
getActiveRange: jest.fn().mockReturnValue({ getRow: () => 2 }), getActiveRange: jest.fn().mockReturnValue({ getRow: () => 2 }),
getRange: jest.fn().mockReturnValue(mockRange) getRange: jest.fn().mockReturnValue(mockRange)
}; };
(global.SpreadsheetApp.getActiveSheet as jest.Mock).mockReturnValue(mockSheet); (global.SpreadsheetApp.getActiveSheet as jest.Mock).mockReturnValue(mockSheet);
mockRange.getValues.mockReturnValueOnce([["sku", "title"]]); mockRange.getValues.mockReturnValueOnce([["sku", "title", "product_type", "product_style"]]);
mockRange.getValues.mockReturnValueOnce([["SKU-1", "Product-1"]]); mockRange.getValues.mockReturnValueOnce([["SKU-1", "Product-1", "T-Shirt", "Regular"]]);
// Mock Template chain // Mock Template chain
const mockHtml = { const mockHtml = {
@ -492,13 +529,20 @@ describe("mediaHandlers", () => {
const mockTemplate = { const mockTemplate = {
evaluate: jest.fn().mockReturnValue(mockHtml), evaluate: jest.fn().mockReturnValue(mockHtml),
initialSku: "", initialSku: "",
initialTitle: "" initialTitle: "",
initialProductType: "",
initialProductStyle: ""
} }
;(global.HtmlService.createTemplateFromFile as jest.Mock).mockReturnValue(mockTemplate) ;(global.HtmlService.createTemplateFromFile as jest.Mock).mockReturnValue(mockTemplate)
showMediaManager() showMediaManager()
expect(global.HtmlService.createTemplateFromFile).toHaveBeenCalledWith("MediaManager") expect(global.HtmlService.createTemplateFromFile).toHaveBeenCalledWith("MediaManager")
expect(mockTemplate.initialSku).toBe("SKU-1")
expect(mockTemplate.initialTitle).toBe("Product-1")
expect(mockTemplate.initialProductType).toBe("T-Shirt")
expect(mockTemplate.initialProductStyle).toBe("Regular")
expect(mockTemplate.evaluate).toHaveBeenCalled() expect(mockTemplate.evaluate).toHaveBeenCalled()
expect(mockHtml.setTitle).toHaveBeenCalledWith("Media Manager") expect(mockHtml.setTitle).toHaveBeenCalledWith("Media Manager")
expect(mockHtml.setWidth).toHaveBeenCalledWith(1100) expect(mockHtml.setWidth).toHaveBeenCalledWith(1100)
@ -506,7 +550,7 @@ describe("mediaHandlers", () => {
expect(mockUi.showModalDialog).toHaveBeenCalledWith(mockHtml, "Media Manager") expect(mockUi.showModalDialog).toHaveBeenCalledWith(mockHtml, "Media Manager")
}) })
test("getSelectedProductInfo should return sku and title from sheet", () => { test("getSelectedProductInfo should return sku, title, description, type, style from sheet", () => {
// Mock SpreadsheetApp behavior specifically for the optimized implementation // Mock SpreadsheetApp behavior specifically for the optimized implementation
// The implementation calls: // The implementation calls:
// 1. sheet.getRange(1, 1, 1, lastCol).getValues()[0] (headers) // 1. sheet.getRange(1, 1, 1, lastCol).getValues()[0] (headers)
@ -518,7 +562,7 @@ describe("mediaHandlers", () => {
const mockSheet = { const mockSheet = {
getName: jest.fn().mockReturnValue("product_inventory"), getName: jest.fn().mockReturnValue("product_inventory"),
getLastColumn: jest.fn().mockReturnValue(3), getLastColumn: jest.fn().mockReturnValue(4),
getActiveRange: jest.fn().mockReturnValue({ getRow: () => 5 }), getActiveRange: jest.fn().mockReturnValue({ getRow: () => 5 }),
getRange: jest.fn().mockReturnValue(mockRange) getRange: jest.fn().mockReturnValue(mockRange)
}; };
@ -526,12 +570,56 @@ describe("mediaHandlers", () => {
(global.SpreadsheetApp.getActiveSheet as jest.Mock).mockReturnValue(mockSheet); (global.SpreadsheetApp.getActiveSheet as jest.Mock).mockReturnValue(mockSheet);
// First call: Headers // First call: Headers
mockRange.getValues.mockReturnValueOnce([["sku", "title", "thumbnail"]]); mockRange.getValues.mockReturnValueOnce([["sku", "title", "body_html", "product_type", "product_style"]]);
// Second call: Row Values // Second call: Row Values
mockRange.getValues.mockReturnValueOnce([["TEST-SKU", "Test Product Title", "thumb.jpg"]]); mockRange.getValues.mockReturnValueOnce([["TEST-SKU", "Test Product Title", "Desc", "Shirt", "Vintage"]]);
const info = getSelectedProductInfo() const info = getSelectedProductInfo()
expect(info).toEqual({ sku: "TEST-SKU", title: "Test Product Title" }) expect(info).toEqual({ sku: "TEST-SKU", title: "Test Product Title", description: "Desc", productType: "Shirt", productStyle: "Vintage" })
})
test("saveProductDefinition should update sheet and generate SKU", () => {
const mockRange = {
getRow: () => 5,
getValues: jest.fn().mockReturnValue([["sku", "title", "product_type", "product_style", "body_html"]]) // Headers
};
const mockSheet = {
getName: jest.fn().mockReturnValue("product_inventory"),
getActiveRange: jest.fn().mockReturnValue(mockRange),
getLastColumn: jest.fn().mockReturnValue(5),
getRange: jest.fn().mockReturnValue(mockRange)
};
(global.SpreadsheetApp.getActiveSheet as jest.Mock).mockReturnValue(mockSheet);
const mockSSInstance = {
setCellValueByColumnName: jest.fn()
};
(GASSpreadsheetService as unknown as jest.Mock).mockReturnValue(mockSSInstance);
(newSku as jest.Mock).mockReturnValue("SKU-123");
const result = saveProductDefinition("TypeA", "StyleB", "Title", "Desc");
expect(mockSSInstance.setCellValueByColumnName).toHaveBeenCalledWith("product_inventory", 5, "product_type", "TypeA");
expect(mockSSInstance.setCellValueByColumnName).toHaveBeenCalledWith("product_inventory", 5, "product_style", "StyleB");
expect(mockSSInstance.setCellValueByColumnName).toHaveBeenCalledWith("product_inventory", 5, "title", "Title");
expect(mockSSInstance.setCellValueByColumnName).toHaveBeenCalledWith("product_inventory", 5, "body_html", "Desc");
expect(newSku).toHaveBeenCalledWith(5);
expect(result).toBe("SKU-123");
})
test("generateSkuForActiveRow should delegate to newSku", () => {
const mockSheet = {
getName: jest.fn().mockReturnValue("product_inventory"),
getActiveRange: jest.fn().mockReturnValue({ getRow: () => 5 })
};
(global.SpreadsheetApp.getActiveSheet as jest.Mock).mockReturnValue(mockSheet);
;(newSku as jest.Mock).mockReturnValue("SKU-GEN-123");
const result = generateSkuForActiveRow();
expect(newSku).toHaveBeenCalledWith(5);
expect(result).toBe("SKU-GEN-123");
}) })
test("getPickerConfig should return config", () => { test("getPickerConfig should return config", () => {
@ -552,6 +640,51 @@ describe("mediaHandlers", () => {
debugScopes() debugScopes()
expect(ScriptApp.getOAuthToken).toHaveBeenCalled() expect(ScriptApp.getOAuthToken).toHaveBeenCalled()
}) })
test("getMediaManagerInitialState should return state with product options", () => {
// Mock SpreadsheetApp behavior to simulate NO SKU selected
// so that getSelectedProductInfo returns empty/null SKU
const mockRange = { getValues: jest.fn() };
const mockSheet = {
getName: jest.fn().mockReturnValue("product_inventory"),
getLastColumn: jest.fn().mockReturnValue(5),
getActiveRange: jest.fn().mockReturnValue({ getRow: () => 5 }),
getRange: jest.fn().mockReturnValue(mockRange)
};
(global.SpreadsheetApp.getActiveSheet as jest.Mock).mockReturnValue(mockSheet);
// First call: Headers (1st execution)
mockRange.getValues.mockReturnValueOnce([["sku", "title", "body_html", "product_type", "product_style"]]);
// Second call: Row Values (1st execution)
mockRange.getValues.mockReturnValueOnce([["", "", "", "", ""]]);
// First call: Headers (2nd execution)
mockRange.getValues.mockReturnValueOnce([["sku", "title", "body_html", "product_type", "product_style"]]);
// Second call: Row Values (2nd execution)
mockRange.getValues.mockReturnValueOnce([["", "", "", "", ""]]);
// Mock value sheet reads via getColumnValuesByName
const mockValues = [["TypeA"], ["TypeB"], ["TypeC"]];
(getColumnValuesByName as jest.Mock).mockReturnValue(mockValues);
const mockSpreadsheet = {
getSheetByName: jest.fn().mockImplementation((name) => {
return name === "values" ? {} : null;
})
};
(global.SpreadsheetApp.getActiveSpreadsheet as jest.Mock).mockReturnValue(mockSpreadsheet);
const state = getMediaManagerInitialState();
expect(state.productOptions).toBeDefined();
expect(state.productOptions?.types).toEqual(["TypeA", "TypeB", "TypeC"]);
// Since we use same mock return for both calls in the implementation if we just mocked the util
expect(state.productOptions?.styles).toEqual(["TypeA", "TypeB", "TypeC"]);
})
}) })
}) })

View File

@ -6,6 +6,17 @@ import { MediaService } from "./services/MediaService"
import { Shop } from "./shopifyApi" import { Shop } from "./shopifyApi"
import { Config } from "./config" import { Config } from "./config"
import { Product } from "./Product" import { Product } from "./Product"
import { newSku } from "./newSku"
import { getColumnValuesByName } from "./sheetUtils"
export function generateSkuForActiveRow() {
const sheet = SpreadsheetApp.getActiveSheet()
if (sheet.getName() !== "product_inventory") throw new Error("Active sheet must be product_inventory")
const row = sheet.getActiveRange().getRow()
if (row <= 1) throw new Error("Invalid row")
return newSku(row)
}
export function showMediaManager() { export function showMediaManager() {
const productInfo = getSelectedProductInfo(); const productInfo = getSelectedProductInfo();
@ -14,6 +25,9 @@ export function showMediaManager() {
// Pass variables to template // Pass variables to template
(template as any).initialSku = productInfo ? productInfo.sku : ""; (template as any).initialSku = productInfo ? productInfo.sku : "";
(template as any).initialTitle = productInfo ? productInfo.title : ""; (template as any).initialTitle = productInfo ? productInfo.title : "";
(template as any).initialDescription = productInfo ? productInfo.description : "";
(template as any).initialProductType = productInfo ? productInfo.productType : "";
(template as any).initialProductStyle = productInfo ? productInfo.productStyle : "";
const html = template.evaluate() const html = template.evaluate()
.setTitle("Media Manager") .setTitle("Media Manager")
@ -22,7 +36,7 @@ export function showMediaManager() {
SpreadsheetApp.getUi().showModalDialog(html, "Media Manager"); SpreadsheetApp.getUi().showModalDialog(html, "Media Manager");
} }
export function getSelectedProductInfo(): { sku: string, title: string } | null { export function getSelectedProductInfo(): { sku: string, title: string, description: string, productType: string, productStyle: string } | null {
const ss = new GASSpreadsheetService() const ss = new GASSpreadsheetService()
// Optimization: Direct usage to avoid multiple service calls overhead // Optimization: Direct usage to avoid multiple service calls overhead
@ -43,6 +57,11 @@ export function getSelectedProductInfo(): { sku: string, title: string } | null
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0] as string[]; const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0] as string[];
const skuIdx = headers.indexOf("sku"); const skuIdx = headers.indexOf("sku");
const titleIdx = headers.indexOf("title"); const titleIdx = headers.indexOf("title");
const descIdx = headers.indexOf("body_html") !== -1 ? headers.indexOf("body_html") :
headers.indexOf("Description") !== -1 ? headers.indexOf("Description") :
headers.indexOf("description");
const typeIdx = headers.indexOf("product_type");
const styleIdx = headers.indexOf("product_style");
if (skuIdx === -1) return null; // No SKU column if (skuIdx === -1) return null; // No SKU column
@ -52,8 +71,17 @@ export function getSelectedProductInfo(): { sku: string, title: string } | null
const sku = rowValues[skuIdx]; const sku = rowValues[skuIdx];
const title = titleIdx !== -1 ? rowValues[titleIdx] : ""; const title = titleIdx !== -1 ? rowValues[titleIdx] : "";
const description = descIdx !== -1 ? rowValues[descIdx] : "";
const productType = typeIdx !== -1 ? rowValues[typeIdx] : "";
const productStyle = styleIdx !== -1 ? rowValues[styleIdx] : "";
return sku ? { sku: String(sku), title: String(title || "") } : null return {
sku: String(sku || ""),
title: String(title || ""),
description: String(description || ""),
productType: String(productType || ""),
productStyle: String(productStyle || "")
}
} }
export function getPickerConfig() { export function getPickerConfig() {
@ -317,9 +345,11 @@ export function getMediaDiagnostics(sku: string) {
export function getMediaManagerInitialState(providedSku?: string, providedTitle?: string): { export function getMediaManagerInitialState(providedSku?: string, providedTitle?: string): {
sku: string | null, sku: string | null,
title: string, title: string,
description?: string,
diagnostics: any, diagnostics: any,
media: any[], media: any[],
token: string token: string,
productOptions?: { types: string[], styles: string[] }
} { } {
let sku = providedSku; let sku = providedSku;
let title = providedTitle || ""; let title = providedTitle || "";
@ -329,16 +359,29 @@ export function getMediaManagerInitialState(providedSku?: string, providedTitle?
if (info) { if (info) {
sku = info.sku; sku = info.sku;
title = info.title; title = info.title;
// We don't have a direct field for description in return type yet, let's add it
} }
} }
// Fetch Product Options for dropdowns (always needed for definition UI)
const productOptions = getProductOptionsFromValuesSheet();
// Re-fetch info to get description if we didn't get it above (or just rely on what we have)
let description = "";
if (!sku) {
const info = getSelectedProductInfo();
if (info) description = info.description;
}
if (!sku) { if (!sku) {
return { return {
sku: null, sku: null,
title: "", title: "",
description,
diagnostics: null, diagnostics: null,
media: [], media: [],
token: ScriptApp.getOAuthToken() token: ScriptApp.getOAuthToken(),
productOptions
} }
} }
@ -360,15 +403,64 @@ export function getMediaManagerInitialState(providedSku?: string, providedTitle?
const shopifyId = product.shopify_id || "" const shopifyId = product.shopify_id || ""
const initialState = mediaService.getInitialState(sku, shopifyId); const initialState = mediaService.getInitialState(sku, shopifyId);
return { return {
sku, sku,
title, title,
description: "", // Fallback or fetch if needed for existing products? For now mostly needed for new ones.
diagnostics: initialState.diagnostics, diagnostics: initialState.diagnostics,
media: initialState.media, media: initialState.media,
token: ScriptApp.getOAuthToken() token: ScriptApp.getOAuthToken(),
productOptions
} }
} }
function getProductOptionsFromValuesSheet() {
// Helper to get unique non-empty values
const getUnique = (colName: string) => {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("values");
if (!sheet) return [];
const values = getColumnValuesByName(sheet, colName); // from sheetUtils
if (!values) return [];
return [...new Set(values.map(v => String(v[0]).trim()).filter(v => v !== "" && v !== colName))];
}
return {
types: getUnique("product_type"),
styles: getUnique("product_style")
};
}
export function saveProductDefinition(productType: string, productStyle: string, title: string, description: string) {
const sheet = SpreadsheetApp.getActiveSheet();
if (sheet.getName() !== "product_inventory") throw new Error("Active sheet must be product_inventory");
const row = sheet.getActiveRange().getRow();
if (row <= 1) throw new Error("Invalid row");
const ss = new GASSpreadsheetService();
// Update columns
ss.setCellValueByColumnName("product_inventory", row, "product_type", productType);
ss.setCellValueByColumnName("product_inventory", row, "product_style", productStyle);
// Description Column Resolution
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0] as string[];
const descColName = headers.includes("body_html") ? "body_html" :
headers.includes("Description") ? "Description" :
headers.includes("description") ? "description" : null;
if (title) ss.setCellValueByColumnName("product_inventory", row, "title", title);
// Save Description if column exists (allow empty string to clear)
if (descColName) {
ss.setCellValueByColumnName("product_inventory", row, descColName, description || "");
}
// Attempt to generate SKU immediately
const sku = newSku(row);
return sku; // Returns new SKU string or undefined
}
export function linkDriveFileToShopifyMedia(sku: string, driveId: string, shopifyId: string) { export function linkDriveFileToShopifyMedia(sku: string, driveId: string, shopifyId: string) {
const config = new Config() const config = new Config()
const driveService = new GASDriveService() const driveService = new GASDriveService()

134
src/newSku.test.ts Normal file
View File

@ -0,0 +1,134 @@
import { newSku } from "./newSku"
import { Shop } from "./shopifyApi"
import {
getCellRangeByColumnName,
getCellValueByColumnName,
getColumnValuesByName,
vlookupByColumns,
} from "./sheetUtils"
// Mock dependencies
jest.mock("./sheetUtils")
jest.mock("./shopifyApi")
// Mock Google Apps Script global
global.SpreadsheetApp = {
getActive: jest.fn().mockReturnValue({
getSheetByName: jest.fn().mockReturnValue({}),
}),
} as any
describe("newSku", () => {
let mockSheet: any
let mockShop: any
const mockSkuCell = {
getValue: jest.fn(),
setValue: jest.fn(),
}
beforeEach(() => {
jest.clearAllMocks()
mockSheet = SpreadsheetApp.getActive().getSheetByName("product_inventory")
// Setup default sheetUtils mocks
;(getCellRangeByColumnName as jest.Mock).mockReturnValue(mockSkuCell)
;(getCellValueByColumnName as jest.Mock).mockImplementation((sheet, col, row) => {
if (col === "shopify_id") return null // Default: No existing Shopify ID
if (col === "product_type") return "T-Shirt"
if (col === "product_style") return "Regular"
return null
})
;(getColumnValuesByName as jest.Mock).mockReturnValue([]) // Default: No existing SKUs
;(vlookupByColumns as jest.Mock).mockImplementation((sheet, searchCol, searchKey, resCol) => {
if (searchKey === "T-Shirt") return "TS"
if (searchKey === "Regular") return "R"
return null
})
// Setup Shop mock
mockShop = {
GetProductById: jest.fn()
}
;(Shop as unknown as jest.Mock).mockImplementation(() => mockShop)
})
it("should generate a new SKU if no Shopify ID exists", () => {
mockSkuCell.getValue.mockReturnValue("?") // Trigger condition
// Expected: TS (Prefix) + R (Suffix) + -0001
const result = newSku(2)
expect(result).toBe("TSR-0001")
expect(mockSkuCell.setValue).toHaveBeenCalledWith("TSR-0001")
})
it("should increment SKU based on existing max ID", () => {
mockSkuCell.getValue.mockReturnValue("?")
// Mock existing SKUs
;(getColumnValuesByName as jest.Mock).mockReturnValue(["TSR-0005", "TSR-0002", "OTHER-0001"])
const result = newSku(2)
expect(result).toBe("TSR-0006")
expect(mockSkuCell.setValue).toHaveBeenCalledWith("TSR-0006")
})
it("should use existing Shopify SKU if shopify_id is present and product has SKU", () => {
mockSkuCell.getValue.mockReturnValue("?")
// Mock Shopify ID present in sheet
;(getCellValueByColumnName as jest.Mock).mockImplementation((sheet, col, row) => {
if (col === "shopify_id") return "gid://shopify/Product/123"
return null
})
// Mock Shopify API return
mockShop.GetProductById.mockReturnValue({
variants: {
nodes: [{ sku: "EXISTING-SKU-123" }]
}
})
const result = newSku(2)
expect(result).toBe("EXISTING-SKU-123")
expect(mockSkuCell.setValue).toHaveBeenCalledWith("EXISTING-SKU-123")
// Should NOT look up types/styles if found in Shopify
expect(vlookupByColumns).not.toHaveBeenCalled()
})
it("should fall back to generation if Shopify product has no SKU", () => {
mockSkuCell.getValue.mockReturnValue("?")
// Mock Shopify ID present
;(getCellValueByColumnName as jest.Mock).mockImplementation((sheet, col, row) => {
if (col === "shopify_id") return "gid://shopify/Product/123"
if (col === "product_type") return "T-Shirt"
if (col === "product_style") return "Regular"
return null
})
// Mock Shopify API return (Empty/No SKU)
mockShop.GetProductById.mockReturnValue({
variants: {
nodes: [{ sku: "" }]
}
})
const result = newSku(2)
// Should generate new one
expect(result).toBe("TSR-0001")
expect(mockSkuCell.setValue).toHaveBeenCalledWith("TSR-0001")
})
it("should not overwrite safe-to-keep values", () => {
mockSkuCell.getValue.mockReturnValue("KEEP-ME")
const result = newSku(2)
expect(result).toBeUndefined()
expect(mockSkuCell.setValue).not.toHaveBeenCalled()
})
})

View File

@ -5,7 +5,9 @@ import {
getCellRangeByColumnName, getCellRangeByColumnName,
getCellValueByColumnName, getCellValueByColumnName,
getColumnValuesByName, getColumnValuesByName,
vlookupByColumns,
} from "./sheetUtils" } from "./sheetUtils"
import { Shop } from "./shopifyApi"
const LOCK_TIMEOUT_MS = 1000 * 10 const LOCK_TIMEOUT_MS = 1000 * 10
@ -16,21 +18,27 @@ export function newSkuHandler(e: GoogleAppsScript.Events.SheetsOnEdit) {
return return
} }
let row = e.range.getRowIndex() let row = e.range.getRowIndex()
let idCell = getCellRangeByColumnName(sheet, "#", row) let skuCell = getCellRangeByColumnName(sheet, "sku", row)
let idCellValue = idCell.getValue() let skuCellValue = skuCell.getValue()
console.log("idCellValue = '" + idCellValue + "'") console.log("skuCellValue = '" + skuCellValue + "'")
if (idCellValue != "?" && idCellValue != "n") {
console.log("new ID was not requested, returning") // Only proceed if SKU is strictly '?' or 'n'
// (We don't want to overwrite blank cells that might just be new rows)
if (skuCellValue != "?" && skuCellValue != "n") {
console.log("new SKU was not requested (must be '?' or 'n'), returning")
return return
} }
// Acquire a user lock to prevent multiple onEdit calls from clashing // Acquire a user lock to prevent multiple onEdit calls from clashing
const documentLock = LockService.getDocumentLock() const documentLock = LockService.getDocumentLock()
try { try {
const config = new (Config); const config = new (Config);
documentLock.waitLock(LOCK_TIMEOUT_MS) documentLock.waitLock(LOCK_TIMEOUT_MS)
const sku = newSku(row) const sku = newSku(row)
console.log("new sku: " + sku) if (sku) {
createPhotoFolderForSku(config, String(sku)) console.log("new sku: " + sku)
createPhotoFolderForSku(config, String(sku))
}
} catch (error) { } catch (error) {
console.log("Error in newSkuHandler: " + error.message) console.log("Error in newSkuHandler: " + error.message)
} finally { } finally {
@ -40,43 +48,84 @@ export function newSkuHandler(e: GoogleAppsScript.Events.SheetsOnEdit) {
export function newSku(row: number) { export function newSku(row: number) {
let sheet = SpreadsheetApp.getActive().getSheetByName("product_inventory") let sheet = SpreadsheetApp.getActive().getSheetByName("product_inventory")
let skuPrefixCol = getColumnByName(sheet, "sku_prefix")
console.log("skuPrefixCol: " + skuPrefixCol) let skuCell = getCellRangeByColumnName(sheet, "sku", row)
let idCol = getColumnByName(sheet, "#")
console.log("idCol: " + idCol)
let idCell = getCellRangeByColumnName(sheet, "#", row)
let safeToOverwrite: string[] = ["?", "n", ""] let safeToOverwrite: string[] = ["?", "n", ""]
let idCellValue = idCell.getValue() let currentSku = skuCell.getValue()
let skuPrefixCellValue = getCellValueByColumnName(sheet, "sku_prefix", row)
console.log("skuPrefixCellValue = '" + skuPrefixCellValue + "'") if (!safeToOverwrite.includes(currentSku)) {
if (!safeToOverwrite.includes(idCellValue)) { // Double check we aren't overwriting a valid SKU
console.log("ID '" + idCellValue + "' is not safe to overwrite, returning") console.log("SKU '" + currentSku + "' is not safe to overwrite, returning")
return return
} }
// 1. Check for existing Shopify SKU (Safety Check)
let shopifyId = getCellValueByColumnName(sheet, "shopify_id", row)
if (shopifyId && shopifyId !== "?" && shopifyId !== "n" && shopifyId !== "") {
console.log(`Checking Shopify for existing SKU (ID: ${shopifyId})`)
const shop = new Shop()
const product = shop.GetProductById(shopifyId)
if (product && product.variants && product.variants.nodes.length > 0) {
const existingSku = product.variants.nodes[0].sku
if (existingSku) {
console.log(`Found existing SKU in Shopify: ${existingSku}. Using it.`)
skuCell.setValue(existingSku)
return existingSku
}
}
}
// 2. Get Product Type & Style
let productType = getCellValueByColumnName(sheet, "product_type", row)
let productStyle = getCellValueByColumnName(sheet, "product_style", row)
if (!productType || !productStyle) {
console.log("Missing product_type or product_style, cannot generate SKU")
return
}
// Lookup Prefix & Suffix
// product_type -> sku_prefix (in values sheet)
let skuPrefix = vlookupByColumns("values", "product_type", productType, "sku_prefix")
// product_style -> sku_suffix (in values sheet)
// Note: Plan says "type_sku_code" -> "sku_suffix", assuming column rename happened or mapped via values sheet
let skuSuffix = vlookupByColumns("values", "product_style", productStyle, "sku_suffix")
if (!skuPrefix) {
console.log(`Could not find sku_prefix for product_type '${productType}'`)
return
}
if (!skuSuffix) {
console.log(`Could not find sku_suffix for product_style '${productStyle}'`)
return
}
let codeBase = `${skuPrefix}${skuSuffix}`
// Find next ID
var skuArray = getColumnValuesByName(sheet, "sku") var skuArray = getColumnValuesByName(sheet, "sku")
var regExp = new RegExp(`^` + skuPrefixCellValue + `-0*(\\d+)$`) // Regex: PrefixSuffix + "-0*" + (digits)
// e.g. TSR-0001
var regExp = new RegExp(`^` + codeBase + `-0*(\\d+)$`)
console.log("regExp: " + regExp.toString()) console.log("regExp: " + regExp.toString())
var maxId = 0 var maxId = 0
for (let i = 0; i < skuArray.length; i++) { for (let i = 0; i < skuArray.length; i++) {
console.log("checking row " + (i + 1)) if (null == skuArray[i] || String(skuArray[i]) == "") continue
if (null == skuArray[i] || String(skuArray[i]) == "") {
console.log("SKU cell looks null")
continue
}
console.log("SKU cell: '" + skuArray[i] + "'")
var match = regExp.exec(String(skuArray[i]))
if (null === match) {
console.log("SKU cell did not match")
continue
}
let numId = Number(match[1])
console.log("match: '" + match + "', numId: " + numId)
maxId = Math.max(numId, maxId)
console.log("numId: " + numId + ", maxId: " + maxId)
}
let newId = maxId + 1
console.log("newId: " + newId)
idCell.setValue(newId)
return `${skuPrefixCellValue}-${newId.toString().padStart(4, "0")}` var match = regExp.exec(String(skuArray[i]))
if (null === match) continue
let numId = Number(match[1])
maxId = Math.max(numId, maxId)
}
let newId = maxId + 1
let newSku = `${codeBase}-${newId.toString().padStart(4, "0")}`
console.log("Generated SKU: " + newSku)
skuCell.setValue(newSku)
return newSku
} }

View File

@ -9,8 +9,7 @@ import {
export function productTemplate(row: number) { export function productTemplate(row: number) {
//TODO: just use the columns that exist, if they match //TODO: just use the columns that exist, if they match
let updateColumns = [ let updateColumns = [
"function", "product_style",
"type",
"category", "category",
"product_type", "product_type",
"tags", "tags",

View File

@ -37,6 +37,9 @@ export function getColumnByName(
) { ) {
let data = sheet.getRange("A1:1").getValues() let data = sheet.getRange("A1:1").getValues()
let column = data[0].indexOf(columnName) let column = data[0].indexOf(columnName)
if (column === -1) {
return -1
}
return column + 1 return column + 1
} }

View File

@ -529,7 +529,7 @@ export class Shop implements IShop {
let done = false let done = false
let query = "" let query = ""
let cursor = "" let cursor = ""
let fields = ["id", "title"] let fields = ["id", "title", "handle"]
var response = { var response = {
content: {}, content: {},
headers: {}, headers: {},
@ -538,7 +538,7 @@ export class Shop implements IShop {
do { do {
let pq = new ShopifyProductsQuery(query, fields, cursor) let pq = new ShopifyProductsQuery(query, fields, cursor)
response = this.shopifyGraphQLAPI(pq.JSON) response = this.shopifyGraphQLAPI(pq.JSON)
console.log(response) // console.log(response)
let productsResponse = new ShopifyProductsResponse(response.content) let productsResponse = new ShopifyProductsResponse(response.content)
if (productsResponse.products.edges.length <= 0) { if (productsResponse.products.edges.length <= 0) {
console.log("no products returned") console.log("no products returned")
@ -547,9 +547,9 @@ export class Shop implements IShop {
} }
for (let i = 0; i < productsResponse.products.edges.length; i++) { for (let i = 0; i < productsResponse.products.edges.length; i++) {
let edge = productsResponse.products.edges[i] let edge = productsResponse.products.edges[i]
console.log(JSON.stringify(edge)) // console.log(JSON.stringify(edge))
let p = new ShopifyProduct() let p = new ShopifyProduct()
Object.assign(edge.node, p) Object.assign(p, edge.node)
products.push(p) products.push(p)
} }
if (productsResponse.products.pageInfo.hasNextPage) { if (productsResponse.products.pageInfo.hasNextPage) {
@ -558,6 +558,7 @@ export class Shop implements IShop {
done = true done = true
} }
} while (!done) } while (!done)
return products
} }
GetProductBySku(sku: string) { GetProductBySku(sku: string) {
@ -1094,6 +1095,7 @@ export class ShopifyProductsQuery {
variants(first:1) { variants(first:1) {
nodes { nodes {
id id
sku
} }
} }
options { options {