From 28034bb694cb1b06f412d2f1f8d67b3baef490c2 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Thu, 11 Dec 2025 15:03:23 -0500 Subject: [PATCH 1/9] Add scaffolding for ios-strings support, notably IOSStringsFormatter class, and IOSStringsOutput file --- lib/src/formatters/index.ts | 3 + lib/src/formatters/iosStrings.ts | 162 ++++++++++++++++++ lib/src/formatters/json.ts | 11 +- lib/src/formatters/shared/base.ts | 7 +- .../shared/fileTypes/IOSStringsOutputFile.ts | 25 +++ lib/src/http/projects.test.ts | 82 +++++++++ lib/src/http/projects.ts | 34 ++++ lib/src/http/textItems.ts | 41 ++++- lib/src/http/types.ts | 19 ++ lib/src/outputs/index.ts | 3 +- lib/src/outputs/iosStrings.ts | 7 + 11 files changed, 374 insertions(+), 20 deletions(-) create mode 100644 lib/src/formatters/iosStrings.ts create mode 100644 lib/src/formatters/shared/fileTypes/IOSStringsOutputFile.ts create mode 100644 lib/src/http/projects.test.ts create mode 100644 lib/src/http/projects.ts create mode 100644 lib/src/outputs/iosStrings.ts diff --git a/lib/src/formatters/index.ts b/lib/src/formatters/index.ts index d02e8d8..8dcaf5a 100644 --- a/lib/src/formatters/index.ts +++ b/lib/src/formatters/index.ts @@ -1,6 +1,7 @@ import { CommandMetaFlags } from "../http/types"; import { Output } from "../outputs"; import { ProjectConfigYAML } from "../services/projectConfig"; +import IOSStringsFormatter from "./iosStrings"; import JSONFormatter from "./json"; export default function formatOutput( @@ -11,6 +12,8 @@ export default function formatOutput( switch (output.format) { case "json": return new JSONFormatter(output, projectConfig, meta).format(); + case "ios-strings": + return new IOSStringsFormatter(output, projectConfig, meta).format(); default: throw new Error(`Unsupported output format: ${output}`); } diff --git a/lib/src/formatters/iosStrings.ts b/lib/src/formatters/iosStrings.ts new file mode 100644 index 0000000..edcf83a --- /dev/null +++ b/lib/src/formatters/iosStrings.ts @@ -0,0 +1,162 @@ +import fetchText from "../http/textItems"; +import { Component, ComponentsResponse, ExportTextItemsResponse, isTextItem, PullFilters, PullQueryParams, TextItem, TextItemsResponse } from "../http/types"; +import fetchComponents from "../http/components"; +import fetchVariables, { Variable } from "../http/variables"; +import BaseFormatter from "./shared/base"; +import OutputFile from "./shared/fileTypes/OutputFile"; +import JSONOutputFile from "./shared/fileTypes/JSONOutputFile"; +import { applyMixins } from "./shared"; +import { getFrameworkProcessor } from "./frameworks/json"; +import fetchProjects from "../http/projects"; +import IOSStringsOutputFile from "./shared/fileTypes/IOSStringsOutputFile"; + +interface ProjectTextItemsMap { + [projectId: string]: ExportTextItemsResponse +} + +type IOSStringsAPIData = { + projects: ProjectTextItemsMap; + components: ComponentsResponse; + variablesById: Record; +}; + +type RequestType = "textItem" | "component"; + +export default class IOSStringsFormatter extends applyMixins( + BaseFormatter) { + + protected async fetchAPIData() { + const projects = await this.fetchProjectTextItemsMap(); + const components = await this.fetchComponents(); + const variables = await this.fetchVariables(); + + const variablesById = variables.reduce((acc, variable) => { + acc[variable.id] = variable; + return acc; + }, {} as Record); + + return { projects, variablesById, components }; + } + + protected async transformAPIData(data: IOSStringsAPIData) { + console.log("DATA: ", data.projects) + Object.keys(data.projects).map((projectId: string) => { + const fileName = `${projectId}___${variantId || "base"}`; + this.outputFiles[fileName] ??= new IOSStringsOutputFile({ + filename: fileName, + path: this.outDir, + metadata: { variantId: textEntity.variantId || "base" }, + }); + }) + + this.outputFiles[fileName] ??= new IOSStringsOutputFile({ + filename: fileName, + path: this.outDir, + metadata: { variantId: textEntity.variantId || "base" }, + }); + let results: OutputFile[] = [ + ...Object.values(this.outputFiles), + this.variablesOutputFile, + ] + + if (this.output.framework) { + // process framework + results.push(...getFrameworkProcessor(this.output).process(this.outputFiles)); + } + } + + private generateTextItemPullFilter() { + let filters: PullFilters = { + projects: this.projectConfig.projects, + variants: this.projectConfig.variants, + }; + + if (this.output.projects) { + filters.projects = this.output.projects; + } + + if (this.output.variants) { + filters.variants = this.output.variants; + } + + return filters; + } + + private generateComponentPullFilter() { + let filters: PullFilters = { + ...(this.projectConfig.components?.folders && { folders: this.projectConfig.components.folders }), + variants: this.projectConfig.variants, + }; + + if (this.output.components) { + filters.folders = this.output.components?.folders; + } + + if (this.output.variants) { + filters.variants = this.output.variants; + } + + return filters; + } + + /** + * Returns the query parameters for the fetchText API request + */ + private generateQueryParams(requestType: RequestType, additionalFilterParams = {}): PullQueryParams { + const filter = requestType === "textItem" ? this.generateTextItemPullFilter() : this.generateComponentPullFilter(); + + let params: PullQueryParams = { + filter: JSON.stringify({ ...filter, ...additionalFilterParams }), + }; + + if (this.projectConfig.richText) { + params.richText = this.projectConfig.richText; + } + + if (this.output.richText) { + params.richText = this.output.richText; + } + + + return { ...params, format: 'ios-strings' }; + } + + /** + * Fetches text item data via API. + * Skips the fetch request if projects field is not specified in config. + * + * @returns text items data + */ + private async fetchProjectTextItemsMap(): Promise { + if (!this.projectConfig.projects && !this.output.projects) return {}; + let projects: { id: string }[] = this.output.projects ?? this.projectConfig.projects ?? []; + const result: ProjectTextItemsMap = {}; + + if (projects.length === 0) { + projects = await fetchProjects(this.meta); + } + + for (const project of projects) { + const projectIosStringsFile = await fetchText(this.generateQueryParams("textItem", { projects: [{ id: project.id }] }), this.meta); + result[project.id] = projectIosStringsFile; + } + + return result; + } + + /** + * Fetches component data via API. + * Skips the fetch request if components field is not specified in config. + * + * @returns components data + */ + private async fetchComponents() { + if (!this.projectConfig.components && !this.output.components) return []; + + return await fetchComponents(this.generateQueryParams("component"), this.meta); + } + + private async fetchVariables() { + return await fetchVariables(this.meta); + } +} diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 8c189f3..8536ff6 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -20,7 +20,8 @@ export default class JSONFormatter extends applyMixins( BaseFormatter) { protected async fetchAPIData() { - const textItems = await this.fetchTextItems(); + console.log('this', this) + const textItems = await this.fetchTextItems() as TextItemsResponse; const components = await this.fetchComponents(); const variables = await this.fetchVariables(); @@ -44,13 +45,13 @@ export default class JSONFormatter extends applyMixins( } let results: OutputFile[] = [ - ...Object.values(this.outputJsonFiles), + ...Object.values(this.outputFiles), this.variablesOutputFile, ] if (this.output.framework) { // process framework - results.push(...getFrameworkProcessor(this.output).process(this.outputJsonFiles)); + results.push(...getFrameworkProcessor(this.output).process(this.outputFiles)); } return results; @@ -64,7 +65,7 @@ export default class JSONFormatter extends applyMixins( private transformAPITextEntity(textEntity: TextItem | Component, variablesById: Record) { const fileName = isTextItem(textEntity) ? `${textEntity.projectId}___${textEntity.variantId || "base"}` : `components___${textEntity.variantId || "base"}`; - this.outputJsonFiles[fileName] ??= new JSONOutputFile({ + this.outputFiles[fileName] ??= new JSONOutputFile({ filename: fileName, path: this.outDir, metadata: { variantId: textEntity.variantId || "base" }, @@ -78,7 +79,7 @@ export default class JSONFormatter extends applyMixins( ? textEntity.richText : textEntity.text; - this.outputJsonFiles[fileName].content[textEntity.id] = textValue; + this.outputFiles[fileName].content[textEntity.id] = textValue; for (const variableId of textEntity.variableIds) { const variable = variablesById[variableId]; this.variablesOutputFile.content[variableId] = variable.data; diff --git a/lib/src/formatters/shared/base.ts b/lib/src/formatters/shared/base.ts index 797ff0c..bc2c4d8 100644 --- a/lib/src/formatters/shared/base.ts +++ b/lib/src/formatters/shared/base.ts @@ -11,10 +11,7 @@ export default class BaseFormatter { protected output: Output; protected projectConfig: ProjectConfigYAML; protected outDir: string; - protected outputJsonFiles: Record< - string, - JSONOutputFile<{ variantId: string }> - >; + protected outputFiles: Record>; protected variablesOutputFile: JSONOutputFile; protected meta: CommandMetaFlags; @@ -26,7 +23,7 @@ export default class BaseFormatter { this.output = output; this.projectConfig = projectConfig; this.outDir = output.outDir ?? appContext.outDir; - this.outputJsonFiles = {}; + this.outputFiles = {}; this.variablesOutputFile = new JSONOutputFile({ filename: "variables", path: this.outDir, diff --git a/lib/src/formatters/shared/fileTypes/IOSStringsOutputFile.ts b/lib/src/formatters/shared/fileTypes/IOSStringsOutputFile.ts new file mode 100644 index 0000000..7906936 --- /dev/null +++ b/lib/src/formatters/shared/fileTypes/IOSStringsOutputFile.ts @@ -0,0 +1,25 @@ +import OutputFile from "./OutputFile"; + +export default class IOSStringsOutputFile extends OutputFile< + Record, + MetadataType +> { + constructor(config: { + filename: string; + path: string; + content?: Record; + metadata?: MetadataType; + }) { + super({ + filename: config.filename, + path: config.path, + extension: "strings", + content: config.content ?? {}, + metadata: config.metadata ?? ({} as MetadataType), + }); + } + + get formattedContent(): string { + return JSON.stringify(this.content, null, 2); + } +} diff --git a/lib/src/http/projects.test.ts b/lib/src/http/projects.test.ts new file mode 100644 index 0000000..9bc91aa --- /dev/null +++ b/lib/src/http/projects.test.ts @@ -0,0 +1,82 @@ +import { AxiosError } from "axios"; +import getHttpClient from "./client"; +import fetchProjects from "./projects"; + +jest.mock("./client"); + +describe("fetchProjects", () => { + // Create a mock client with a mock 'get' method + const mockHttpClient = { + get: jest.fn(), + }; + + // Make getHttpClient return the mock client + (getHttpClient as jest.Mock).mockReturnValue(mockHttpClient); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should parse response correctly", async () => { + const mockResponse = { + data: [ + { + id: "project1", + name: "Project One", + }, + { + id: "project2", + name: "Project Two", + }, + ], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + + const result = await fetchProjects({}); + + expect(result).toEqual([...mockResponse.data]); + }); + + it("should handle empty response", async () => { + const mockResponse = { + data: [], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + + const result = await fetchProjects({}); + + expect(result).toEqual([]); + }); + + it("should handle error responses", async () => { + const mockError = new AxiosError("Request failed"); + mockError.response = { + status: 400, + data: { + message: "Invalid filter format", + }, + } as any; + + mockHttpClient.get.mockRejectedValue(mockError); + + await expect(fetchProjects({})).rejects.toThrow( + "Invalid filter format. Please check your project filters and try again." + ); + }); + + it("should handle error responses without message", async () => { + const mockError = new AxiosError("Request failed"); + mockError.response = { + status: 400, + data: {}, + } as any; + + mockHttpClient.get.mockRejectedValue(mockError); + + await expect(fetchProjects({})).rejects.toThrow( + "Invalid project filters. Please check your project filters and try again." + ); + }); +}); diff --git a/lib/src/http/projects.ts b/lib/src/http/projects.ts new file mode 100644 index 0000000..a14e3ec --- /dev/null +++ b/lib/src/http/projects.ts @@ -0,0 +1,34 @@ +import { AxiosError } from "axios"; +import { ZProjectsResponse, CommandMetaFlags } from "./types"; +import getHttpClient from "./client"; + +export default async function fetchProjects(meta: CommandMetaFlags) { + try { + const httpClient = getHttpClient({ meta }); + const response = await httpClient.get("/v2/projects"); + + return ZProjectsResponse.parse(response.data); + } catch (e) { + if (!(e instanceof AxiosError)) { + throw new Error( + "Sorry! We're having trouble reaching the Ditto API. Please try again later." + ); + } + + // Handle invalid filters + if (e.response?.status === 400) { + let errorMsgBase = "Invalid project filters"; + + if (e.response?.data?.message) errorMsgBase = e.response.data.message; + + throw new Error( + `${errorMsgBase}. Please check your project filters and try again.`, + { + cause: e.response?.data, + } + ); + } + + throw e; + } +} diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index 57d4618..6cebb23 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -1,17 +1,18 @@ import httpClient from "./client"; import { AxiosError } from "axios"; -import { CommandMetaFlags, PullQueryParams, ZTextItemsResponse } from "./types"; +import { + CommandMetaFlags, + PullQueryParams, + ZTextItemsResponse, + ZExportTextItemsResponse, + ExportTextItemsResponse, + TextItemsResponse, +} from "./types"; import getHttpClient from "./client"; -export default async function fetchText( - params: PullQueryParams, - meta: CommandMetaFlags -) { +function fetchTextWrapper(cb: () => Promise) { try { - const httpClient = getHttpClient({ meta }); - const response = await httpClient.get("/v2/textItems", { params }); - - return ZTextItemsResponse.parse(response.data); + return cb(); } catch (e: unknown) { if (!(e instanceof AxiosError)) { throw new Error( @@ -36,3 +37,25 @@ export default async function fetchText( throw e; } } + +export default async function fetchText( + params: PullQueryParams, + meta: CommandMetaFlags +) { + switch (params.format) { + case "ios-strings": + return fetchTextWrapper(async () => { + const httpClient = getHttpClient({ meta }); + const response = await httpClient.get("/v2/textItems/export", { + params, + }); + return ZExportTextItemsResponse.parse(response.data) as TResponse; + }); + default: + return fetchTextWrapper(async () => { + const httpClient = getHttpClient({ meta }); + const response = await httpClient.get("/v2/textItems", { params }); + return ZTextItemsResponse.parse(response.data) as TResponse; + }); + } +} diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index bd11c07..7054bd4 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -12,6 +12,7 @@ export interface PullFilters { export interface PullQueryParams { filter: string; // Stringified PullFilters richText?: "html"; + format?: "ios-strings" | undefined; } const ZBaseTextEntity = z.object({ @@ -41,6 +42,9 @@ export type TextItem = z.infer; export const ZTextItemsResponse = z.array(ZTextItem); export type TextItemsResponse = z.infer; +export const ZExportTextItemsResponse = z.string(); +export type ExportTextItemsResponse = z.infer; + // MARK - Components const ZComponent = ZBaseTextEntity.extend({ @@ -55,6 +59,21 @@ export type Component = z.infer; export const ZComponentsResponse = z.array(ZComponent); export type ComponentsResponse = z.infer; +// MARK - Projects + +const ZProject = z.object({ + id: z.string(), + name: z.string(), +}); + +/** + * Represents a single project, as returned from the /v2/projects endpoint + */ +export type Project = z.infer; + +export const ZProjectsResponse = z.array(ZProject); +export type ProjectsResponse = z.infer; + /** * Contains metadata attached to CLI commands via -m or --meta flag * Currently only used internally to identify requests from our GitHub Action diff --git a/lib/src/outputs/index.ts b/lib/src/outputs/index.ts index 303b95b..ae109ce 100644 --- a/lib/src/outputs/index.ts +++ b/lib/src/outputs/index.ts @@ -1,9 +1,10 @@ import { z } from "zod"; import { ZJSONOutput } from "./json"; +import { ZIOSStringsOutput } from "./iosStrings"; /** * The output config is a discriminated union of all the possible output formats. */ -export const ZOutput = z.union([...ZJSONOutput.options]); +export const ZOutput = z.union([...ZJSONOutput.options, ZIOSStringsOutput]); export type Output = z.infer; diff --git a/lib/src/outputs/iosStrings.ts b/lib/src/outputs/iosStrings.ts new file mode 100644 index 0000000..1dd64b4 --- /dev/null +++ b/lib/src/outputs/iosStrings.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; +import { ZBaseOutputFilters } from "./shared"; + +export const ZIOSStringsOutput = ZBaseOutputFilters.extend({ + format: z.literal("ios-strings"), + framework: z.undefined(), +}).strict(); From 7a2d014ec071758cf06c19fd6844c2852ac32f2c Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Thu, 11 Dec 2025 17:10:47 -0500 Subject: [PATCH 2/9] Add /v2/variants endpoint. Update IOSStringsFormatter to pull down variants and projects as expected. Moved shared BaseFormatter class methods out of JSON and IOSStringsFormatters --- lib/src/formatters/iosStrings.ts | 128 ++--- lib/src/formatters/json.ts | 65 +-- lib/src/formatters/shared/base.test.ts | 451 ++++++++++++++++++ lib/src/formatters/shared/base.ts | 74 ++- .../shared/fileTypes/IOSStringsOutputFile.ts | 9 +- lib/src/http/types.ts | 16 + lib/src/http/variants.test.ts | 102 ++++ lib/src/http/variants.ts | 35 ++ 8 files changed, 726 insertions(+), 154 deletions(-) create mode 100644 lib/src/formatters/shared/base.test.ts create mode 100644 lib/src/http/variants.test.ts create mode 100644 lib/src/http/variants.ts diff --git a/lib/src/formatters/iosStrings.ts b/lib/src/formatters/iosStrings.ts index edcf83a..6cab20e 100644 --- a/lib/src/formatters/iosStrings.ts +++ b/lib/src/formatters/iosStrings.ts @@ -1,21 +1,22 @@ import fetchText from "../http/textItems"; -import { Component, ComponentsResponse, ExportTextItemsResponse, isTextItem, PullFilters, PullQueryParams, TextItem, TextItemsResponse } from "../http/types"; +import { ComponentsResponse, ExportTextItemsResponse, PullQueryParams } from "../http/types"; import fetchComponents from "../http/components"; import fetchVariables, { Variable } from "../http/variables"; import BaseFormatter from "./shared/base"; import OutputFile from "./shared/fileTypes/OutputFile"; -import JSONOutputFile from "./shared/fileTypes/JSONOutputFile"; import { applyMixins } from "./shared"; -import { getFrameworkProcessor } from "./frameworks/json"; import fetchProjects from "../http/projects"; import IOSStringsOutputFile from "./shared/fileTypes/IOSStringsOutputFile"; +import fetchVariants from "../http/variants"; -interface ProjectTextItemsMap { - [projectId: string]: ExportTextItemsResponse +interface TextItemsMap { + [projectId: string]: { + [variantId: string]: ExportTextItemsResponse + } } type IOSStringsAPIData = { - projects: ProjectTextItemsMap; + textItemsMap: TextItemsMap; components: ComponentsResponse; variablesById: Record; }; @@ -23,10 +24,10 @@ type IOSStringsAPIData = { type RequestType = "textItem" | "component"; export default class IOSStringsFormatter extends applyMixins( - BaseFormatter) { + BaseFormatter, IOSStringsAPIData>) { protected async fetchAPIData() { - const projects = await this.fetchProjectTextItemsMap(); + const textItemsMap = await this.fetchTextItemsMap(); const components = await this.fetchComponents(); const variables = await this.fetchVariables(); @@ -35,91 +36,30 @@ export default class IOSStringsFormatter extends applyMixins( return acc; }, {} as Record); - return { projects, variablesById, components }; + return { textItemsMap, variablesById, components }; } protected async transformAPIData(data: IOSStringsAPIData) { - console.log("DATA: ", data.projects) - Object.keys(data.projects).map((projectId: string) => { - const fileName = `${projectId}___${variantId || "base"}`; - this.outputFiles[fileName] ??= new IOSStringsOutputFile({ - filename: fileName, - path: this.outDir, - metadata: { variantId: textEntity.variantId || "base" }, + Object.entries(data.textItemsMap).forEach(([projectId, projectVariants]) => { + Object.entries(projectVariants).forEach(([variantId, iosStringsFile]) => { + const fileName = `${projectId}___${variantId || "base"}`; + this.outputFiles[fileName] ??= new IOSStringsOutputFile({ + filename: fileName, + path: this.outDir, + metadata: { variantId: variantId || "base" }, + content: iosStringsFile + }); }); - }) - - this.outputFiles[fileName] ??= new IOSStringsOutputFile({ - filename: fileName, - path: this.outDir, - metadata: { variantId: textEntity.variantId || "base" }, }); + let results: OutputFile[] = [ ...Object.values(this.outputFiles), this.variablesOutputFile, ] - if (this.output.framework) { - // process framework - results.push(...getFrameworkProcessor(this.output).process(this.outputFiles)); - } - } - - private generateTextItemPullFilter() { - let filters: PullFilters = { - projects: this.projectConfig.projects, - variants: this.projectConfig.variants, - }; - - if (this.output.projects) { - filters.projects = this.output.projects; - } - - if (this.output.variants) { - filters.variants = this.output.variants; - } - - return filters; - } - - private generateComponentPullFilter() { - let filters: PullFilters = { - ...(this.projectConfig.components?.folders && { folders: this.projectConfig.components.folders }), - variants: this.projectConfig.variants, - }; - - if (this.output.components) { - filters.folders = this.output.components?.folders; - } - - if (this.output.variants) { - filters.variants = this.output.variants; - } - - return filters; + return results; } - /** - * Returns the query parameters for the fetchText API request - */ - private generateQueryParams(requestType: RequestType, additionalFilterParams = {}): PullQueryParams { - const filter = requestType === "textItem" ? this.generateTextItemPullFilter() : this.generateComponentPullFilter(); - - let params: PullQueryParams = { - filter: JSON.stringify({ ...filter, ...additionalFilterParams }), - }; - - if (this.projectConfig.richText) { - params.richText = this.projectConfig.richText; - } - - if (this.output.richText) { - params.richText = this.output.richText; - } - - - return { ...params, format: 'ios-strings' }; - } /** * Fetches text item data via API. @@ -127,18 +67,36 @@ export default class IOSStringsFormatter extends applyMixins( * * @returns text items data */ - private async fetchProjectTextItemsMap(): Promise { + private async fetchTextItemsMap(): Promise { if (!this.projectConfig.projects && !this.output.projects) return {}; let projects: { id: string }[] = this.output.projects ?? this.projectConfig.projects ?? []; - const result: ProjectTextItemsMap = {}; + let variants: { id: string }[] = this.output.variants ?? this.projectConfig.variants ?? []; + + const result: TextItemsMap = {}; if (projects.length === 0) { projects = await fetchProjects(this.meta); } + if (variants.some((variant) => variant.id === 'all')) { + variants = await fetchVariants(this.meta); + } else if (variants.length === 0) { + variants = [{ id: 'base' }] + } + for (const project of projects) { - const projectIosStringsFile = await fetchText(this.generateQueryParams("textItem", { projects: [{ id: project.id }] }), this.meta); - result[project.id] = projectIosStringsFile; + result[project.id] = {}; + + for (const variant of variants) { + // map "base" to undefined, as by default export endpoint returns base variant + const variantsParam = variant.id === 'base' ? undefined : [{ id: variant.id }] + const params: PullQueryParams = { + ...super.generateQueryParams("textItem", { projects: [{ id: project.id }], variants: variantsParam }), + format: 'ios-strings' + }; + const iosStringsFile = await fetchText(params, this.meta); + result[project.id][variant.id] = iosStringsFile; + } } return result; diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 8536ff6..5e0ecd7 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -14,13 +14,10 @@ type JSONAPIData = { variablesById: Record; }; -type RequestType = "textItem" | "component"; - export default class JSONFormatter extends applyMixins( - BaseFormatter) { + BaseFormatter, JSONAPIData>) { protected async fetchAPIData() { - console.log('this', this) const textItems = await this.fetchTextItems() as TextItemsResponse; const components = await this.fetchComponents(); const variables = await this.fetchVariables(); @@ -86,62 +83,6 @@ export default class JSONFormatter extends applyMixins( } } - private generateTextItemPullFilter() { - let filters: PullFilters = { - projects: this.projectConfig.projects, - variants: this.projectConfig.variants, - }; - - if (this.output.projects) { - filters.projects = this.output.projects; - } - - if (this.output.variants) { - filters.variants = this.output.variants; - } - - return filters; - } - - private generateComponentPullFilter() { - let filters: PullFilters = { - ...(this.projectConfig.components?.folders && { folders: this.projectConfig.components.folders }), - variants: this.projectConfig.variants, - }; - - if (this.output.components) { - filters.folders = this.output.components?.folders; - } - - if (this.output.variants) { - filters.variants = this.output.variants; - } - - return filters; - } - - /** - * Returns the query parameters for the fetchText API request - */ - private generateQueryParams(requestType: RequestType) { - const filter = requestType === "textItem" ? this.generateTextItemPullFilter() : this.generateComponentPullFilter(); - - let params: PullQueryParams = { - filter: JSON.stringify(filter), - }; - - if (this.projectConfig.richText) { - params.richText = this.projectConfig.richText; - } - - if (this.output.richText) { - params.richText = this.output.richText; - } - - - return params; - } - /** * Fetches text item data via API. * Skips the fetch request if projects field is not specified in config. @@ -151,7 +92,7 @@ export default class JSONFormatter extends applyMixins( private async fetchTextItems() { if (!this.projectConfig.projects && !this.output.projects) return []; - return await fetchText(this.generateQueryParams("textItem"), this.meta); + return await fetchText(super.generateQueryParams("textItem"), this.meta); } /** @@ -163,7 +104,7 @@ export default class JSONFormatter extends applyMixins( private async fetchComponents() { if (!this.projectConfig.components && !this.output.components) return []; - return await fetchComponents(this.generateQueryParams("component"), this.meta); + return await fetchComponents(super.generateQueryParams("component"), this.meta); } private async fetchVariables() { diff --git a/lib/src/formatters/shared/base.test.ts b/lib/src/formatters/shared/base.test.ts new file mode 100644 index 0000000..6c03f9f --- /dev/null +++ b/lib/src/formatters/shared/base.test.ts @@ -0,0 +1,451 @@ +import BaseFormatter from "./base"; +import { Output } from "../../outputs"; +import { ProjectConfigYAML } from "../../services/projectConfig"; +import { CommandMetaFlags, PullFilters } from "../../http/types"; +import JSONOutputFile from "./fileTypes/JSONOutputFile"; + +/** + * Test subclass that exposes protected/private methods for testing + */ +// @ts-ignore +class TestBaseFormatter extends BaseFormatter { + // Expose private methods for testing + public generateTextItemPullFilter() { + return super["generateTextItemPullFilter"](); + } + + public generateComponentPullFilter() { + return super["generateComponentPullFilter"](); + } + + // Expose protected method for testing + public generateQueryParams( + requestType: "textItem" | "component", + filter: PullFilters = {} + ) { + return super.generateQueryParams(requestType, filter); + } +} + +describe("BaseFormatter", () => { + const createMockOutput = (overrides: Partial = {}): Output => ({ + format: "json", + ...overrides, + }); + + const createMockProjectConfig = ( + overrides: Partial = {} + ): ProjectConfigYAML => ({ + projects: [], + variants: [], + components: { + folders: [], + }, + outputs: [ + { + format: "json", + }, + ], + ...overrides, + }); + + const createMockMeta = (): CommandMetaFlags => ({}); + + /*********************************************************** + * generateTextItemPullFilter + ***********************************************************/ + + describe("generateTextItemPullFilter", () => { + it("should use projectConfig projects and variants when output does not override", () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }, { id: "project2" }], + variants: [{ id: "variant1" }], + }); + const output = createMockOutput(); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const filters = formatter.generateTextItemPullFilter(); + + expect(filters).toEqual({ + projects: [{ id: "project1" }, { id: "project2" }], + variants: [{ id: "variant1" }], + }); + }); + + it("should override projects with output.projects when provided", () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }, { id: "project2" }], + variants: [{ id: "variant1" }], + }); + const output = createMockOutput({ + projects: [{ id: "project3" }], + }); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const filters = formatter.generateTextItemPullFilter(); + + expect(filters).toEqual({ + projects: [{ id: "project3" }], + variants: [{ id: "variant1" }], + }); + }); + + it("should override variants with output.variants when provided", () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [{ id: "variant1" }], + }); + const output = createMockOutput({ + variants: [{ id: "variant2" }, { id: "variant3" }], + }); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const filters = formatter.generateTextItemPullFilter(); + + expect(filters).toEqual({ + projects: [{ id: "project1" }], + variants: [{ id: "variant2" }, { id: "variant3" }], + }); + }); + + it("should override both projects and variants when both are provided in output", () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [{ id: "variant1" }], + }); + const output = createMockOutput({ + projects: [{ id: "project2" }], + variants: [{ id: "variant2" }], + }); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const filters = formatter.generateTextItemPullFilter(); + + expect(filters).toEqual({ + projects: [{ id: "project2" }], + variants: [{ id: "variant2" }], + }); + }); + + it("should handle undefined projects and variants in projectConfig", () => { + const projectConfig = createMockProjectConfig({ + projects: undefined, + variants: undefined, + }); + const output = createMockOutput(); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const filters = formatter.generateTextItemPullFilter(); + + expect(filters).toEqual({ + projects: undefined, + variants: undefined, + }); + }); + }); + + /*********************************************************** + * generateComponentPullFilter + ***********************************************************/ + describe("generateComponentPullFilter", () => { + const getComponentPullFilters = ( + mockProjectConfig: any, + mockOutput?: any + ) => { + const projectConfig = createMockProjectConfig(mockProjectConfig); + const output = createMockOutput(mockOutput); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + return formatter.generateComponentPullFilter(); + }; + + it("should use projectConfig components.folders and variants when output is not provided", () => { + const filters = getComponentPullFilters({ + components: { + folders: [ + { id: "folder1" }, + { id: "folder2", excludeNestedFolders: true }, + ], + }, + variants: [{ id: "variant1" }], + }); + + expect(filters).toEqual({ + folders: [ + { id: "folder1" }, + { id: "folder2", excludeNestedFolders: true }, + ], + variants: [{ id: "variant1" }], + }); + }); + + it("should not include folders when projectConfig.components.folders is undefined", () => { + const filters = getComponentPullFilters({ + components: { + folders: undefined, + }, + variants: [{ id: "variant1" }], + }); + + expect(filters).toEqual({ + variants: [{ id: "variant1" }], + }); + expect(filters.folders).toBeUndefined(); + }); + + it("should override folders with output.components.folders when provided", () => { + const filters = getComponentPullFilters( + { + components: { + folders: [{ id: "folder1" }], + }, + variants: [{ id: "variant1" }], + }, + { + components: { + folders: [{ id: "folder2" }], + }, + } + ); + + expect(filters).toEqual({ + folders: [{ id: "folder2" }], + variants: [{ id: "variant1" }], + }); + }); + + it("should override variants with output.variants when provided", () => { + const filters = getComponentPullFilters( + { + components: { + folders: [{ id: "folder1" }], + }, + variants: [{ id: "variant1" }], + }, + { + variants: [{ id: "variant2" }], + } + ); + + expect(filters).toEqual({ + folders: [{ id: "folder1" }], + variants: [{ id: "variant2" }], + }); + }); + + it("should override both folders and variants when both are provided in output", () => { + const filters = getComponentPullFilters( + { + components: { + folders: [{ id: "folder1" }], + }, + variants: [{ id: "variant1" }], + }, + { + components: { + folders: [{ id: "folder2" }], + }, + variants: [{ id: "variant2" }], + } + ); + expect(filters).toEqual({ + folders: [{ id: "folder2" }], + variants: [{ id: "variant2" }], + }); + }); + + it("should handle undefined components in projectConfig", () => { + const filters = getComponentPullFilters({ + components: undefined, + variants: [{ id: "variant1" }], + }); + + expect(filters).toEqual({ + variants: [{ id: "variant1" }], + }); + expect(filters.folders).toBeUndefined(); + }); + }); + + /*********************************************************** + * generateQueryParams + ***********************************************************/ + + describe("generateQueryParams", () => { + it("should generate query params for RequestType: textItem", () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [{ id: "variant1" }], + }); + const output = createMockOutput(); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const params = formatter.generateQueryParams("textItem"); + + expect(params.filter).toBeDefined(); + const parsedFilter = JSON.parse(params.filter); + expect(parsedFilter).toEqual({ + projects: [{ id: "project1" }], + variants: [{ id: "variant1" }], + }); + expect(params.richText).toBeUndefined(); + }); + + it("should generate query params for RequestType: component", () => { + const projectConfig = createMockProjectConfig({ + components: { + folders: [{ id: "folder1" }], + }, + variants: [{ id: "variant1" }], + }); + const output = createMockOutput(); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const params = formatter.generateQueryParams("component"); + + expect(params.filter).toBeDefined(); + const parsedFilter = JSON.parse(params.filter); + expect(parsedFilter).toEqual({ + folders: [{ id: "folder1" }], + variants: [{ id: "variant1" }], + }); + expect(params.richText).toBeUndefined(); + }); + + it("should merge additional filter with base filter", () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [{ id: "variant1" }], + }); + const output = createMockOutput(); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const additionalFilter: PullFilters = { + projects: [{ id: "project2" }], + }; + const params = formatter.generateQueryParams( + "textItem", + additionalFilter + ); + + expect(params.filter).toBeDefined(); + const parsedFilter = JSON.parse(params.filter); + expect(parsedFilter).toEqual({ + projects: [{ id: "project2" }], // Additional filter overrides base + variants: [{ id: "variant1" }], + }); + }); + + it("should include richText from projectConfig when set", () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + richText: "html", + }); + const output = createMockOutput(); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const params = formatter.generateQueryParams("textItem"); + + expect(params.richText).toBe("html"); + }); + + it("should override projectConfig richText with output richText when both are set", () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + richText: false, + }); + const output = createMockOutput({ + richText: "html", + }); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const params = formatter.generateQueryParams("textItem"); + + expect(params.richText).toBe("html"); + }); + + it("should use output richText when only output has richText set", () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + }); + const output = createMockOutput({ + richText: "html", + }); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const params = formatter.generateQueryParams("textItem"); + + expect(params.richText).toBe("html"); + }); + + it("should handle empty filter object", () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [{ id: "variant1" }], + }); + const output = createMockOutput(); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const params = formatter.generateQueryParams("textItem", undefined); + + expect(params.filter).toBeDefined(); + const parsedFilter = JSON.parse(params.filter); + expect(parsedFilter).toEqual({ + projects: [{ id: "project1" }], + variants: [{ id: "variant1" }], + }); + }); + }); +}); diff --git a/lib/src/formatters/shared/base.ts b/lib/src/formatters/shared/base.ts index bc2c4d8..e5b92a3 100644 --- a/lib/src/formatters/shared/base.ts +++ b/lib/src/formatters/shared/base.ts @@ -5,13 +5,18 @@ import { ProjectConfigYAML } from "../../services/projectConfig"; import OutputFile from "./fileTypes/OutputFile"; import appContext from "../../utils/appContext"; import JSONOutputFile from "./fileTypes/JSONOutputFile"; -import { CommandMetaFlags } from "../../http/types"; +import { + CommandMetaFlags, + PullFilters, + PullQueryParams, +} from "../../http/types"; -export default class BaseFormatter { +type RequestType = "textItem" | "component"; +export default class BaseFormatter { protected output: Output; protected projectConfig: ProjectConfigYAML; protected outDir: string; - protected outputFiles: Record>; + protected outputFiles: Record; protected variablesOutputFile: JSONOutputFile; protected meta: CommandMetaFlags; @@ -31,6 +36,69 @@ export default class BaseFormatter { this.meta = meta; } + private generateTextItemPullFilter() { + let filters: PullFilters = { + projects: this.projectConfig.projects, + variants: this.projectConfig.variants, + }; + + if (this.output.projects) { + filters.projects = this.output.projects; + } + + if (this.output.variants) { + filters.variants = this.output.variants; + } + + return filters; + } + + private generateComponentPullFilter() { + let filters: PullFilters = { + ...(this.projectConfig.components?.folders && { + folders: this.projectConfig.components.folders, + }), + variants: this.projectConfig.variants, + }; + + if (this.output.components) { + filters.folders = this.output.components?.folders; + } + + if (this.output.variants) { + filters.variants = this.output.variants; + } + + return filters; + } + + /** + * Returns the query parameters for the fetchText API request + */ + protected generateQueryParams( + requestType: RequestType, + filter: PullFilters = {} + ): PullQueryParams { + const baseFilter = + requestType === "textItem" + ? this.generateTextItemPullFilter() + : this.generateComponentPullFilter(); + + let params: PullQueryParams = { + filter: JSON.stringify({ ...baseFilter, ...filter }), + }; + + if (this.projectConfig.richText) { + params.richText = this.projectConfig.richText; + } + + if (this.output.richText) { + params.richText = this.output.richText; + } + + return params; + } + protected async fetchAPIData(): Promise { return {} as APIDataType; } diff --git a/lib/src/formatters/shared/fileTypes/IOSStringsOutputFile.ts b/lib/src/formatters/shared/fileTypes/IOSStringsOutputFile.ts index 7906936..6661ab5 100644 --- a/lib/src/formatters/shared/fileTypes/IOSStringsOutputFile.ts +++ b/lib/src/formatters/shared/fileTypes/IOSStringsOutputFile.ts @@ -1,25 +1,26 @@ import OutputFile from "./OutputFile"; +// BP: Is this MetadataType necessary? export default class IOSStringsOutputFile extends OutputFile< - Record, + string, MetadataType > { constructor(config: { filename: string; path: string; - content?: Record; + content?: string; metadata?: MetadataType; }) { super({ filename: config.filename, path: config.path, extension: "strings", - content: config.content ?? {}, + content: config.content ?? "", metadata: config.metadata ?? ({} as MetadataType), }); } get formattedContent(): string { - return JSON.stringify(this.content, null, 2); + return this.content; } } diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index 7054bd4..25459de 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -74,6 +74,22 @@ export type Project = z.infer; export const ZProjectsResponse = z.array(ZProject); export type ProjectsResponse = z.infer; +// MARK - Variants + +const ZVariant = z.object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), +}); + +/** + * Represents a single variant, as returned from the /v2/variants endpoint + */ +export type Variant = z.infer; + +export const ZVariantsResponse = z.array(ZVariant); +export type VariantsResponse = z.infer; + /** * Contains metadata attached to CLI commands via -m or --meta flag * Currently only used internally to identify requests from our GitHub Action diff --git a/lib/src/http/variants.test.ts b/lib/src/http/variants.test.ts new file mode 100644 index 0000000..a34ce61 --- /dev/null +++ b/lib/src/http/variants.test.ts @@ -0,0 +1,102 @@ +import { AxiosError } from "axios"; +import getHttpClient from "./client"; +import fetchVariants from "./variants"; + +jest.mock("./client"); + +describe("fetchVariants", () => { + // Create a mock client with a mock 'get' method + const mockHttpClient = { + get: jest.fn(), + }; + + // Make getHttpClient return the mock client + (getHttpClient as jest.Mock).mockReturnValue(mockHttpClient); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should parse response correctly", async () => { + const mockResponse = { + data: [ + { + id: "variant1", + name: "Variant One", + description: "This is variant one", + }, + { + id: "variant2", + name: "Variant Two", + description: "This is variant two", + }, + ], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + + const result = await fetchVariants({}); + + expect(result).toEqual([...mockResponse.data]); + }); + + it("should handle response without description field", async () => { + const mockResponse = { + data: [ + { + id: "variant1", + name: "Variant One", + }, + ], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + + const result = await fetchVariants({}); + + expect(result).toEqual([...mockResponse.data]); + }); + + it("should handle empty response", async () => { + const mockResponse = { + data: [], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + + const result = await fetchVariants({}); + + expect(result).toEqual([]); + }); + + it("should handle error responses", async () => { + const mockError = new AxiosError("Request failed"); + mockError.response = { + status: 400, + data: { + message: "Invalid filter format", + }, + } as any; + + mockHttpClient.get.mockRejectedValue(mockError); + + await expect(fetchVariants({})).rejects.toThrow( + "Invalid filter format. Please check your variant filters and try again." + ); + }); + + it("should handle error responses without message", async () => { + const mockError = new AxiosError("Request failed"); + mockError.response = { + status: 400, + data: {}, + } as any; + + mockHttpClient.get.mockRejectedValue(mockError); + + await expect(fetchVariants({})).rejects.toThrow( + "Invalid variant filters. Please check your variant filters and try again." + ); + }); +}); + diff --git a/lib/src/http/variants.ts b/lib/src/http/variants.ts new file mode 100644 index 0000000..a475021 --- /dev/null +++ b/lib/src/http/variants.ts @@ -0,0 +1,35 @@ +import { AxiosError } from "axios"; +import { ZVariantsResponse, CommandMetaFlags } from "./types"; +import getHttpClient from "./client"; + +// BP: Add wrapper function to this and other HTTP requests +export default async function fetchVariants(meta: CommandMetaFlags) { + try { + const httpClient = getHttpClient({ meta }); + const response = await httpClient.get("/v2/variants"); + + return ZVariantsResponse.parse(response.data); + } catch (e) { + if (!(e instanceof AxiosError)) { + throw new Error( + "Sorry! We're having trouble reaching the Ditto API. Please try again later." + ); + } + + // Handle invalid filters + if (e.response?.status === 400) { + let errorMsgBase = "Invalid variant filters"; + + if (e.response?.data?.message) errorMsgBase = e.response.data.message; + + throw new Error( + `${errorMsgBase}. Please check your variant filters and try again.`, + { + cause: e.response?.data, + } + ); + } + + throw e; + } +} From 18a29fe2b468f6c5df9e812d6d0c71fcd88ca818 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Fri, 12 Dec 2025 09:35:38 -0500 Subject: [PATCH 3/9] Minor inline doc updates --- lib/src/formatters/iosStrings.ts | 13 ++++++------- lib/src/formatters/shared/base.ts | 2 ++ lib/src/http/textItems.ts | 2 -- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/lib/src/formatters/iosStrings.ts b/lib/src/formatters/iosStrings.ts index 6cab20e..2113ffc 100644 --- a/lib/src/formatters/iosStrings.ts +++ b/lib/src/formatters/iosStrings.ts @@ -21,8 +21,6 @@ type IOSStringsAPIData = { variablesById: Record; }; -type RequestType = "textItem" | "component"; - export default class IOSStringsFormatter extends applyMixins( BaseFormatter, IOSStringsAPIData>) { @@ -62,10 +60,10 @@ export default class IOSStringsFormatter extends applyMixins( /** - * Fetches text item data via API. - * Skips the fetch request if projects field is not specified in config. + * Fetches text item data via API for each configured project and variant + * in this output * - * @returns text items data + * @returns text items mapped to their respective variant and project */ private async fetchTextItemsMap(): Promise { if (!this.projectConfig.projects && !this.output.projects) return {}; @@ -110,8 +108,9 @@ export default class IOSStringsFormatter extends applyMixins( */ private async fetchComponents() { if (!this.projectConfig.components && !this.output.components) return []; - - return await fetchComponents(this.generateQueryParams("component"), this.meta); + const params = this.generateQueryParams("component"); + console.log('params', params) + return await fetchComponents(params, this.meta); } private async fetchVariables() { diff --git a/lib/src/formatters/shared/base.ts b/lib/src/formatters/shared/base.ts index e5b92a3..d184c34 100644 --- a/lib/src/formatters/shared/base.ts +++ b/lib/src/formatters/shared/base.ts @@ -36,6 +36,8 @@ export default class BaseFormatter { this.meta = meta; } + // BP: this might not be supported for export, might need to keep old implementation + //. at the json level, then use this for all the export ones private generateTextItemPullFilter() { let filters: PullFilters = { projects: this.projectConfig.projects, diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index 6cebb23..36c9442 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -5,8 +5,6 @@ import { PullQueryParams, ZTextItemsResponse, ZExportTextItemsResponse, - ExportTextItemsResponse, - TextItemsResponse, } from "./types"; import getHttpClient from "./client"; From 7f18347543b8e84c953a73645df1b06aa712ae5d Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Fri, 12 Dec 2025 11:38:56 -0500 Subject: [PATCH 4/9] Add pull command E2E tests for outputted ios-strings files. Added component mapping to iosStringsFormatter --- lib/src/commands/pull.test.ts | 189 +++++++++++++++++++++++++++++- lib/src/formatters/iosStrings.ts | 70 ++++++----- lib/src/formatters/json.ts | 8 +- lib/src/formatters/shared/base.ts | 2 +- lib/src/http/components.ts | 39 ++++-- lib/src/http/types.ts | 3 + 6 files changed, 267 insertions(+), 44 deletions(-) diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index 1970675..a06164f 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -469,7 +469,7 @@ describe("pull command - end-to-end tests", () => { }); }); - describe("Output files", () => { + describe("Output files - JSON", () => { it("should create output files for each project and variant returned from the API", async () => { fs.mkdirSync(outputDir, { recursive: true }); @@ -604,4 +604,191 @@ describe("pull command - end-to-end tests", () => { ]); }); }); + + // Helper functions + const setupIosStringsMocks = ({ + textItems = [], + components = [], + variables = [], + }: { + textItems: TextItem[]; + components?: Component[]; + variables?: any[]; + }) => { + mockHttpClient.get.mockImplementation((url: string, config?: any) => { + // if (url.includes("/v2/projects")) { + // return Promise.resolve({ + // data: [{ id: "project-1" }, { id: "project-2" }], + // }); + // } + if (url.includes("/v2/textItems/export")) { + return Promise.resolve({ + data: textItems + .map((textItem) => `"${textItem.id}" = "${textItem.text}"`) + .join("\n\n"), + }); + } + if (url.includes("/v2/variables")) { + return Promise.resolve({ data: variables }); + } + if (url.includes("/v2/components/export")) { + return Promise.resolve({ + data: components + .map((component) => `"${component.id}" = "${component.text}"`) + .join("\n\n"), + }); + } + return Promise.resolve({ data: [] }); + }); + }; + + /* + "this-is-a-ditto-text-item" = "No its not"; + + "this-is-a-text-layer-on-figma" = "This is a Ditto text item (LinkedNode)"; + + "update-preferences" = "Update preferences"; + */ + + describe("Output files - ios-strings", () => { + it("should create output files for each project and variant returned from the API", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + components: {}, + outputs: [ + { + format: "ios-strings", + outDir: outputDir, + projects: [{ id: "project-1" }, { id: "project-2" }], + variants: [ + { id: "base" }, + { id: "variant-a" }, + { id: "variant-b" }, + ], + }, + ], + }); + + // project-1 and project-2 each have at least one base text item + const baseTextItems = [ + createMockTextItem({ + projectId: "project-1", + variantId: null, + id: "text-1", + }), + createMockTextItem({ + projectId: "project-1", + variantId: null, + id: "text-2", + }), + createMockTextItem({ + projectId: "project-2", + variantId: null, + id: "text-3", + }), + ]; + + // project-1 and project-2 each have a variant-a text item + const variantATextItems = [ + createMockTextItem({ + projectId: "project-1", + variantId: "variant-a", + id: "text-4", + }), + createMockTextItem({ + projectId: "project-2", + variantId: "variant-a", + id: "text-5", + }), + ]; + + // Only project-1 has variant-b, so only project-1 should get a variant-b file + const variantBTextItems = [ + createMockTextItem({ + projectId: "project-1", + variantId: "variant-b", + id: "text-6", + }), + createMockTextItem({ + projectId: "project-1", + variantId: "variant-b", + id: "text-7", + }), + ]; + + const componentsBase = [ + createMockComponent({ + id: "comp-1", + variantId: null, + folderId: null, + }), + createMockComponent({ + id: "comp-2", + variantId: null, + folderId: "folder-1", + }), + createMockComponent({ + id: "comp-3", + variantId: null, + folderId: "folder-2", + }), + ]; + + const componentsVariantA = [ + createMockComponent({ + id: "comp-4", + variantId: "variant-a", + folderId: null, + }), + createMockComponent({ + id: "comp-5", + variantId: "variant-a", + folderId: "folder-1", + }), + ]; + + const componentsVariantB = [ + createMockComponent({ + id: "comp-6", + variantId: "variant-b", + folderId: null, + }), + createMockComponent({ + id: "comp-7", + variantId: "variant-b", + folderId: "folder-1", + }), + ]; + + setupIosStringsMocks({ + textItems: [ + ...baseTextItems, + ...variantATextItems, + ...variantBTextItems, + ], + components: [ + ...componentsBase, + ...componentsVariantA, + ...componentsVariantB, + ], + }); + + await pull({}); + + // Verify a file was created for each project and variant present in the (mocked) API response + assertFilesCreated(outputDir, [ + "project-1___base.strings", + "project-1___variant-a.strings", + "project-1___variant-b.strings", + "project-2___base.strings", + "project-2___variant-a.strings", + "project-2___variant-b.strings", // BP: Should this not be here? Do we need to check output files to see if empty variant files for a project should be shown? + "components___base.strings", + "components___variant-a.strings", + "components___variant-b.strings", + "variables.json", + ]); + }); + }); }); diff --git a/lib/src/formatters/iosStrings.ts b/lib/src/formatters/iosStrings.ts index 2113ffc..03c664c 100644 --- a/lib/src/formatters/iosStrings.ts +++ b/lib/src/formatters/iosStrings.ts @@ -1,14 +1,15 @@ import fetchText from "../http/textItems"; -import { ComponentsResponse, ExportTextItemsResponse, PullQueryParams } from "../http/types"; +import { ExportComponentsResponse, ExportTextItemsResponse, PullQueryParams } from "../http/types"; import fetchComponents from "../http/components"; -import fetchVariables, { Variable } from "../http/variables"; import BaseFormatter from "./shared/base"; -import OutputFile from "./shared/fileTypes/OutputFile"; import { applyMixins } from "./shared"; import fetchProjects from "../http/projects"; import IOSStringsOutputFile from "./shared/fileTypes/IOSStringsOutputFile"; import fetchVariants from "../http/variants"; +interface ComponentsMap { + [variantId: string]: ExportComponentsResponse +} interface TextItemsMap { [projectId: string]: { [variantId: string]: ExportTextItemsResponse @@ -17,8 +18,7 @@ interface TextItemsMap { type IOSStringsAPIData = { textItemsMap: TextItemsMap; - components: ComponentsResponse; - variablesById: Record; + componentsMap: ComponentsMap; }; export default class IOSStringsFormatter extends applyMixins( @@ -26,18 +26,12 @@ export default class IOSStringsFormatter extends applyMixins( protected async fetchAPIData() { const textItemsMap = await this.fetchTextItemsMap(); - const components = await this.fetchComponents(); - const variables = await this.fetchVariables(); - - const variablesById = variables.reduce((acc, variable) => { - acc[variable.id] = variable; - return acc; - }, {} as Record); + const componentsMap = await this.fetchComponentsMap(); - return { textItemsMap, variablesById, components }; + return { textItemsMap, componentsMap }; } - protected async transformAPIData(data: IOSStringsAPIData) { + protected transformAPIData(data: IOSStringsAPIData) { Object.entries(data.textItemsMap).forEach(([projectId, projectVariants]) => { Object.entries(projectVariants).forEach(([variantId, iosStringsFile]) => { const fileName = `${projectId}___${variantId || "base"}`; @@ -50,15 +44,22 @@ export default class IOSStringsFormatter extends applyMixins( }); }); - let results: OutputFile[] = [ + Object.entries(data.componentsMap).forEach(([variantId, iosStringsFile]) => { + const fileName = `components___${variantId || "base"}`; + this.outputFiles[fileName] ??= new IOSStringsOutputFile({ + filename: fileName, + path: this.outDir, + metadata: { variantId: variantId || "base" }, + content: iosStringsFile + }); + }) + + return [ ...Object.values(this.outputFiles), this.variablesOutputFile, - ] - - return results; + ]; } - /** * Fetches text item data via API for each configured project and variant * in this output @@ -76,6 +77,7 @@ export default class IOSStringsFormatter extends applyMixins( projects = await fetchProjects(this.meta); } + // BP: do this prior to both textItems and components fetching so they can share if (variants.some((variant) => variant.id === 'all')) { variants = await fetchVariants(this.meta); } else if (variants.length === 0) { @@ -106,14 +108,28 @@ export default class IOSStringsFormatter extends applyMixins( * * @returns components data */ - private async fetchComponents() { - if (!this.projectConfig.components && !this.output.components) return []; - const params = this.generateQueryParams("component"); - console.log('params', params) - return await fetchComponents(params, this.meta); - } + private async fetchComponentsMap(): Promise { + if (!this.projectConfig.components && !this.output.components) return {}; + let variants: { id: string }[] = this.output.variants ?? this.projectConfig.variants ?? []; + const result: ComponentsMap = {}; - private async fetchVariables() { - return await fetchVariables(this.meta); + if (variants.some((variant) => variant.id === 'all')) { + variants = await fetchVariants(this.meta); + } else if (variants.length === 0) { + variants = [{ id: 'base' }] + } + + for (const variant of variants) { + // map "base" to undefined, as by default export endpoint returns base variant + const variantsParam = variant.id === 'base' ? undefined : [{ id: variant.id }] + const params: PullQueryParams = { + ...super.generateQueryParams("component", { variants: variantsParam }), + format: 'ios-strings' + }; + const iosStringsFile = await fetchComponents(params, this.meta); + result[variant.id] = iosStringsFile; + } + + return result; } } diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 5e0ecd7..7ac69b5 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -18,7 +18,7 @@ export default class JSONFormatter extends applyMixins( BaseFormatter, JSONAPIData>) { protected async fetchAPIData() { - const textItems = await this.fetchTextItems() as TextItemsResponse; + const textItems = await this.fetchTextItems(); const components = await this.fetchComponents(); const variables = await this.fetchVariables(); @@ -30,7 +30,7 @@ export default class JSONFormatter extends applyMixins( return { textItems, variablesById, components }; } - protected async transformAPIData(data: JSONAPIData) { + protected transformAPIData(data: JSONAPIData) { for (let i = 0; i < data.textItems.length; i++) { const textItem = data.textItems[i]; this.transformAPITextEntity(textItem, data.variablesById); @@ -92,7 +92,7 @@ export default class JSONFormatter extends applyMixins( private async fetchTextItems() { if (!this.projectConfig.projects && !this.output.projects) return []; - return await fetchText(super.generateQueryParams("textItem"), this.meta); + return await fetchText(super.generateQueryParams("textItem"), this.meta); } /** @@ -104,7 +104,7 @@ export default class JSONFormatter extends applyMixins( private async fetchComponents() { if (!this.projectConfig.components && !this.output.components) return []; - return await fetchComponents(super.generateQueryParams("component"), this.meta); + return await fetchComponents(super.generateQueryParams("component"), this.meta); } private async fetchVariables() { diff --git a/lib/src/formatters/shared/base.ts b/lib/src/formatters/shared/base.ts index d184c34..a8762a8 100644 --- a/lib/src/formatters/shared/base.ts +++ b/lib/src/formatters/shared/base.ts @@ -105,7 +105,7 @@ export default class BaseFormatter { return {} as APIDataType; } - protected async transformAPIData(data: APIDataType): Promise { + protected transformAPIData(data: APIDataType): OutputFile[] { return []; } diff --git a/lib/src/http/components.ts b/lib/src/http/components.ts index 70c26cf..ed05e66 100644 --- a/lib/src/http/components.ts +++ b/lib/src/http/components.ts @@ -1,23 +1,16 @@ import { AxiosError } from "axios"; import { ZComponentsResponse, + ZExportComponentsResponse, PullQueryParams, CommandMetaFlags, } from "./types"; import getHttpClient from "./client"; -export default async function fetchComponents( - params: PullQueryParams, - meta: CommandMetaFlags -) { +function fetchComponentsWrapper(cb: () => Promise) { try { - const httpClient = getHttpClient({ meta }); - const response = await httpClient.get("/v2/components", { - params, - }); - - return ZComponentsResponse.parse(response.data); - } catch (e) { + return cb(); + } catch (e: unknown) { if (!(e instanceof AxiosError)) { throw new Error( "Sorry! We're having trouble reaching the Ditto API. Please try again later." @@ -41,3 +34,27 @@ export default async function fetchComponents( throw e; } } + +export default async function fetchComponents( + params: PullQueryParams, + meta: CommandMetaFlags +) { + switch (params.format) { + case "ios-strings": + return fetchComponentsWrapper(async () => { + const httpClient = getHttpClient({ meta }); + const response = await httpClient.get("/v2/components/export", { + params, + }); + return ZExportComponentsResponse.parse(response.data) as TResponse; + }); + default: + return fetchComponentsWrapper(async () => { + const httpClient = getHttpClient({ meta }); + const response = await httpClient.get("/v2/components", { + params, + }); + return ZComponentsResponse.parse(response.data) as TResponse; + }); + } +} diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index 25459de..df18bd5 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -59,6 +59,9 @@ export type Component = z.infer; export const ZComponentsResponse = z.array(ZComponent); export type ComponentsResponse = z.infer; +export const ZExportComponentsResponse = z.string(); +export type ExportComponentsResponse = z.infer; + // MARK - Projects const ZProject = z.object({ From 9ec1af531ca019550378895f2adb452dfad41147 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Fri, 12 Dec 2025 12:08:41 -0500 Subject: [PATCH 5/9] Add unit tests to IOSSTringsFormatter class --- lib/src/commands/pull.test.ts | 20 +- lib/src/formatters/iosStrings.test.ts | 422 ++++++++++++++++++++++++++ lib/src/formatters/iosStrings.ts | 12 +- 3 files changed, 437 insertions(+), 17 deletions(-) create mode 100644 lib/src/formatters/iosStrings.test.ts diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index a06164f..216168d 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -615,12 +615,14 @@ describe("pull command - end-to-end tests", () => { components?: Component[]; variables?: any[]; }) => { + /* + "this-is-a-ditto-text-item" = "No its not"; + + "this-is-a-text-layer-on-figma" = "This is a Ditto text item (LinkedNode)"; + + "update-preferences" = "Update preferences"; + */ mockHttpClient.get.mockImplementation((url: string, config?: any) => { - // if (url.includes("/v2/projects")) { - // return Promise.resolve({ - // data: [{ id: "project-1" }, { id: "project-2" }], - // }); - // } if (url.includes("/v2/textItems/export")) { return Promise.resolve({ data: textItems @@ -642,14 +644,6 @@ describe("pull command - end-to-end tests", () => { }); }; - /* - "this-is-a-ditto-text-item" = "No its not"; - - "this-is-a-text-layer-on-figma" = "This is a Ditto text item (LinkedNode)"; - - "update-preferences" = "Update preferences"; - */ - describe("Output files - ios-strings", () => { it("should create output files for each project and variant returned from the API", async () => { fs.mkdirSync(outputDir, { recursive: true }); diff --git a/lib/src/formatters/iosStrings.test.ts b/lib/src/formatters/iosStrings.test.ts new file mode 100644 index 0000000..3216fe3 --- /dev/null +++ b/lib/src/formatters/iosStrings.test.ts @@ -0,0 +1,422 @@ +import IOSStringsFormatter from "./iosStrings"; +import { Output } from "../outputs"; +import { ProjectConfigYAML } from "../services/projectConfig"; +import { CommandMetaFlags } from "../http/types"; +import { + ExportTextItemsResponse, + ExportComponentsResponse, +} from "../http/types"; +import fetchText from "../http/textItems"; +import fetchComponents from "../http/components"; +import fetchProjects from "../http/projects"; +import fetchVariants from "../http/variants"; +import IOSStringsOutputFile from "./shared/fileTypes/IOSStringsOutputFile"; + +// Mock HTTP functions +jest.mock("../http/textItems"); +jest.mock("../http/components"); +jest.mock("../http/projects"); +jest.mock("../http/variants"); + +const mockFetchText = fetchText as jest.MockedFunction; +const mockFetchComponents = fetchComponents as jest.MockedFunction< + typeof fetchComponents +>; +const mockFetchProjects = fetchProjects as jest.MockedFunction< + typeof fetchProjects +>; +const mockFetchVariants = fetchVariants as jest.MockedFunction< + typeof fetchVariants +>; + +// @ts-ignore +class TestIOSStringsFormatter extends IOSStringsFormatter { + // Expose protected method for testing + public async fetchAPIData() { + return super.fetchAPIData(); + } + + public transformAPIData( + data: Parameters[0] + ) { + return super.transformAPIData(data); + } + + // Expose private methods for testing + public async fetchTextItemsMap() { + return super["fetchTextItemsMap"](); + } + + public async fetchComponentsMap() { + return super["fetchComponentsMap"](); + } +} + +describe("IOSStringsFormatter", () => { + // @ts-ignore + const createMockOutput = (overrides: Partial = {}): Output => ({ + format: "ios-strings", + outDir: "/test/output", + ...overrides, + }); + + const createMockProjectConfig = ( + overrides: Partial = {} + ): ProjectConfigYAML => ({ + projects: [], + variants: [], + components: { + folders: [], + }, + outputs: [ + { + format: "ios-strings", + }, + ], + ...overrides, + }); + + const createMockMeta = (): CommandMetaFlags => ({}); + + const createMockIOSStringsContent = (): ExportTextItemsResponse => + ` + "this-is-a-ditto-text-item" = "No its not"; + + "this-is-a-text-layer-on-figma" = "This is a Ditto text item (LinkedNode)"; + + "update-preferences" = "Update preferences"; + `; + + const createMockComponentsContent = (): ExportComponentsResponse => + ` + "continue" = "Continue"; + + "email" = "Email"; + `; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("fetchTextItemsMap", () => { + it("should fetch text items for projects and variants configured at root level", async () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }, { id: "project2" }], + variants: [{ id: "variant1" }, { id: "base" }], + }); + const output = createMockOutput(); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockContent = createMockIOSStringsContent(); + mockFetchText.mockResolvedValue(mockContent); + + const result = await formatter.fetchTextItemsMap(); + + expect(result).toEqual({ + project1: { + variant1: mockContent, + base: mockContent, + }, + project2: { + variant1: mockContent, + base: mockContent, + }, + }); + }); + + it("should fetch all projects from API when not configured", async () => { + const projectConfig = createMockProjectConfig({ + projects: [], + variants: [{ id: "base" }], + }); + const output = createMockOutput(); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockProjects = [ + { id: "project-1", name: "Project 1" }, + { id: "project-2", name: "Project 2" }, + { id: "project-3", name: "Project 3" }, + { id: "project-4", name: "Project 4" }, + ]; + const mockContent = createMockIOSStringsContent(); + + mockFetchProjects.mockResolvedValue(mockProjects); + mockFetchText.mockResolvedValue(mockContent); + + const result = await formatter.fetchTextItemsMap(); + + expect(mockFetchProjects).toHaveBeenCalled(); + expect(result).toEqual({ + "project-1": { + base: mockContent, + }, + "project-2": { + base: mockContent, + }, + "project-3": { + base: mockContent, + }, + "project-4": { + base: mockContent, + }, + }); + }); + + it("should fetch variants from API when 'all' is specified", async () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [{ id: "all" }], + }); + const output = createMockOutput(); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockVariants = [ + { id: "variant1", name: "Variant 1" }, + { id: "variant2", name: "Variant 2" }, + ]; + const mockContent = createMockIOSStringsContent(); + + mockFetchVariants.mockResolvedValue(mockVariants); + mockFetchText.mockResolvedValue(mockContent); + + const result = await formatter.fetchTextItemsMap(); + + expect(mockFetchVariants).toHaveBeenCalled(); + expect(result).toEqual({ + project1: { + variant1: mockContent, + variant2: mockContent, + }, + }); + }); + + it("should default to base variant when variants are empty", async () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [], + }); + const output = createMockOutput(); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockContent = createMockIOSStringsContent(); + mockFetchText.mockResolvedValue(mockContent); + + const result = await formatter.fetchTextItemsMap(); + + expect(result).toEqual({ + project1: { + base: mockContent, + }, + }); + }); + }); + + describe("fetchComponentsMap", () => { + it("should fetch components for variants configured at root level", async () => { + const projectConfig = createMockProjectConfig({ + variants: [{ id: "variant1" }, { id: "base" }], + components: { + folders: [], + }, + }); + const output = createMockOutput(); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockContent = createMockComponentsContent(); + mockFetchComponents.mockResolvedValue(mockContent); + + const result = await formatter.fetchComponentsMap(); + + expect(result).toEqual({ + variant1: mockContent, + base: mockContent, + }); + + expect(mockFetchComponents).toHaveBeenCalledTimes(2); + }); + + it("should fetch variants from API when 'all' is specified", async () => { + const projectConfig = createMockProjectConfig({ + variants: [{ id: "all" }], + components: { + folders: [], + }, + }); + const output = createMockOutput(); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockVariants = [ + { id: "variant1", name: "Variant 1" }, + { id: "variant2", name: "Variant 2" }, + ]; + const mockContent = createMockComponentsContent(); + + mockFetchVariants.mockResolvedValue(mockVariants); + mockFetchComponents.mockResolvedValue(mockContent); + + const result = await formatter.fetchComponentsMap(); + + expect(mockFetchVariants).toHaveBeenCalled(); + expect(result).toEqual({ variant1: mockContent, variant2: mockContent }); + }); + + it("should default to base variant when variants are empty", async () => { + const projectConfig = createMockProjectConfig({ + variants: [], + components: { + folders: [], + }, + }); + const output = createMockOutput(); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockContent = createMockComponentsContent(); + mockFetchComponents.mockResolvedValue(mockContent); + + const result = await formatter.fetchComponentsMap(); + + expect(result).toEqual({ + base: mockContent, + }); + }); + + it("should return empty object when components not configured", async () => { + const projectConfig = createMockProjectConfig({ + components: undefined, + }); + const output = createMockOutput(); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const result = await formatter.fetchComponentsMap(); + + expect(result).toEqual({}); + expect(mockFetchComponents).not.toHaveBeenCalled(); + }); + }); + + describe("fetchAPIData", () => { + it("should combine text items and components data", async () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [{ id: "base" }], + components: { + folders: [], + }, + }); + const output = createMockOutput(); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockTextContent = createMockIOSStringsContent(); + const mockComponentsContent = createMockComponentsContent(); + + mockFetchText.mockResolvedValue(mockTextContent); + mockFetchComponents.mockResolvedValue(mockComponentsContent); + + const result = await formatter.fetchAPIData(); + + expect(result).toEqual({ + textItemsMap: { + project1: { + base: mockTextContent, + }, + }, + componentsMap: { + base: mockComponentsContent, + }, + }); + }); + }); + + describe("transformAPIData", () => { + it("should transform text items into IOSStringsOutputFile output files", () => { + const projectConfig = createMockProjectConfig(); + const output = createMockOutput({ outDir: "/test/output" }); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockTextContent = createMockIOSStringsContent(); + const data = { + textItemsMap: { + project1: { + base: mockTextContent, + variant1: mockTextContent, + }, + }, + componentsMap: {}, + }; + + const result = formatter.transformAPIData(data); + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(IOSStringsOutputFile); + expect(result[0].filename).toBe("project1___base"); + expect(result[1]).toBeInstanceOf(IOSStringsOutputFile); + expect(result[1].filename).toBe("project1___variant1"); + }); + + it("should transform components into IOSStringsOutputFile output files", () => { + const projectConfig = createMockProjectConfig(); + const output = createMockOutput({ outDir: "/test/output" }); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockComponentsContent = createMockComponentsContent(); + const data = { + textItemsMap: {}, + componentsMap: { + base: mockComponentsContent, + variant1: mockComponentsContent, + }, + }; + + const result = formatter.transformAPIData(data); + + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(IOSStringsOutputFile); + expect(result[0].filename).toBe("components___base"); + expect(result[1]).toBeInstanceOf(IOSStringsOutputFile); + expect(result[1].filename).toBe("components___variant1"); + }); + }); +}); diff --git a/lib/src/formatters/iosStrings.ts b/lib/src/formatters/iosStrings.ts index 03c664c..e6a2342 100644 --- a/lib/src/formatters/iosStrings.ts +++ b/lib/src/formatters/iosStrings.ts @@ -31,6 +31,12 @@ export default class IOSStringsFormatter extends applyMixins( return { textItemsMap, componentsMap }; } + /** + * For each project/variant permutation and its fetched .strings data, + * create a new file with the expected naming + * + * @returns {OutputFile[]} List of Output Files + */ protected transformAPIData(data: IOSStringsAPIData) { Object.entries(data.textItemsMap).forEach(([projectId, projectVariants]) => { Object.entries(projectVariants).forEach(([variantId, iosStringsFile]) => { @@ -54,10 +60,7 @@ export default class IOSStringsFormatter extends applyMixins( }); }) - return [ - ...Object.values(this.outputFiles), - this.variablesOutputFile, - ]; + return Object.values(this.outputFiles); } /** @@ -104,6 +107,7 @@ export default class IOSStringsFormatter extends applyMixins( /** * Fetches component data via API. + * If individual variants configured, fetch by each otherwise fetch for all * Skips the fetch request if components field is not specified in config. * * @returns components data From 78b001ca82de7a99a455213c9016b95fe792bdd0 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Fri, 12 Dec 2025 12:23:45 -0500 Subject: [PATCH 6/9] Update variant fetching to be shared across textItems and components. Unit tests additions to IOSStringsFormatter class --- lib/src/formatters/iosStrings.test.ts | 15 ++++++++++- lib/src/formatters/iosStrings.ts | 39 ++++++++++++++------------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/lib/src/formatters/iosStrings.test.ts b/lib/src/formatters/iosStrings.test.ts index 3216fe3..131e20f 100644 --- a/lib/src/formatters/iosStrings.test.ts +++ b/lib/src/formatters/iosStrings.test.ts @@ -42,6 +42,10 @@ class TestIOSStringsFormatter extends IOSStringsFormatter { return super.transformAPIData(data); } + public async fetchVariants() { + return super["fetchVariants"](); + } + // Expose private methods for testing public async fetchTextItemsMap() { return super["fetchTextItemsMap"](); @@ -114,6 +118,7 @@ describe("IOSStringsFormatter", () => { const mockContent = createMockIOSStringsContent(); mockFetchText.mockResolvedValue(mockContent); + await formatter.fetchVariants(); const result = await formatter.fetchTextItemsMap(); expect(result).toEqual({ @@ -151,6 +156,7 @@ describe("IOSStringsFormatter", () => { mockFetchProjects.mockResolvedValue(mockProjects); mockFetchText.mockResolvedValue(mockContent); + await formatter.fetchVariants(); const result = await formatter.fetchTextItemsMap(); expect(mockFetchProjects).toHaveBeenCalled(); @@ -191,6 +197,7 @@ describe("IOSStringsFormatter", () => { mockFetchVariants.mockResolvedValue(mockVariants); mockFetchText.mockResolvedValue(mockContent); + await formatter.fetchVariants(); const result = await formatter.fetchTextItemsMap(); expect(mockFetchVariants).toHaveBeenCalled(); @@ -217,6 +224,7 @@ describe("IOSStringsFormatter", () => { const mockContent = createMockIOSStringsContent(); mockFetchText.mockResolvedValue(mockContent); + await formatter.fetchVariants(); const result = await formatter.fetchTextItemsMap(); expect(result).toEqual({ @@ -245,6 +253,7 @@ describe("IOSStringsFormatter", () => { const mockContent = createMockComponentsContent(); mockFetchComponents.mockResolvedValue(mockContent); + await formatter.fetchVariants(); const result = await formatter.fetchComponentsMap(); expect(result).toEqual({ @@ -278,6 +287,7 @@ describe("IOSStringsFormatter", () => { mockFetchVariants.mockResolvedValue(mockVariants); mockFetchComponents.mockResolvedValue(mockContent); + await formatter.fetchVariants(); const result = await formatter.fetchComponentsMap(); expect(mockFetchVariants).toHaveBeenCalled(); @@ -301,6 +311,7 @@ describe("IOSStringsFormatter", () => { const mockContent = createMockComponentsContent(); mockFetchComponents.mockResolvedValue(mockContent); + await formatter.fetchVariants(); const result = await formatter.fetchComponentsMap(); expect(result).toEqual({ @@ -327,7 +338,7 @@ describe("IOSStringsFormatter", () => { }); describe("fetchAPIData", () => { - it("should combine text items and components data", async () => { + it("should fetchVariants and combine text items and components data", async () => { const projectConfig = createMockProjectConfig({ projects: [{ id: "project1" }], variants: [{ id: "base" }], @@ -348,8 +359,10 @@ describe("IOSStringsFormatter", () => { mockFetchText.mockResolvedValue(mockTextContent); mockFetchComponents.mockResolvedValue(mockComponentsContent); + const fetchVariantsSpy = jest.spyOn(formatter, "fetchVariants"); const result = await formatter.fetchAPIData(); + expect(fetchVariantsSpy).toHaveBeenCalled(); expect(result).toEqual({ textItemsMap: { project1: { diff --git a/lib/src/formatters/iosStrings.ts b/lib/src/formatters/iosStrings.ts index e6a2342..a209aa3 100644 --- a/lib/src/formatters/iosStrings.ts +++ b/lib/src/formatters/iosStrings.ts @@ -1,5 +1,5 @@ import fetchText from "../http/textItems"; -import { ExportComponentsResponse, ExportTextItemsResponse, PullQueryParams } from "../http/types"; +import { ExportComponentsResponse, ExportTextItemsResponse, PullQueryParams, Variant } from "../http/types"; import fetchComponents from "../http/components"; import BaseFormatter from "./shared/base"; import { applyMixins } from "./shared"; @@ -23,8 +23,10 @@ type IOSStringsAPIData = { export default class IOSStringsFormatter extends applyMixins( BaseFormatter, IOSStringsAPIData>) { + private variants: { id: string }[] = []; protected async fetchAPIData() { + await this.fetchVariants(); const textItemsMap = await this.fetchTextItemsMap(); const componentsMap = await this.fetchComponentsMap(); @@ -63,6 +65,22 @@ export default class IOSStringsFormatter extends applyMixins( return Object.values(this.outputFiles); } + /** + * Sets variants based on configuration + * - Fetches from API if "all" configured + * - Adds "base" variant by default if none configured + */ + private async fetchVariants(): Promise { + let variants: { id: string }[] = this.output.variants ?? this.projectConfig.variants ?? []; + if (variants.some((variant) => variant.id === 'all')) { + variants = await fetchVariants(this.meta); + } else if (variants.length === 0) { + variants = [{ id: 'base' }] + } + + this.variants = variants; + } + /** * Fetches text item data via API for each configured project and variant * in this output @@ -72,7 +90,6 @@ export default class IOSStringsFormatter extends applyMixins( private async fetchTextItemsMap(): Promise { if (!this.projectConfig.projects && !this.output.projects) return {}; let projects: { id: string }[] = this.output.projects ?? this.projectConfig.projects ?? []; - let variants: { id: string }[] = this.output.variants ?? this.projectConfig.variants ?? []; const result: TextItemsMap = {}; @@ -80,17 +97,10 @@ export default class IOSStringsFormatter extends applyMixins( projects = await fetchProjects(this.meta); } - // BP: do this prior to both textItems and components fetching so they can share - if (variants.some((variant) => variant.id === 'all')) { - variants = await fetchVariants(this.meta); - } else if (variants.length === 0) { - variants = [{ id: 'base' }] - } - for (const project of projects) { result[project.id] = {}; - for (const variant of variants) { + for (const variant of this.variants) { // map "base" to undefined, as by default export endpoint returns base variant const variantsParam = variant.id === 'base' ? undefined : [{ id: variant.id }] const params: PullQueryParams = { @@ -114,16 +124,9 @@ export default class IOSStringsFormatter extends applyMixins( */ private async fetchComponentsMap(): Promise { if (!this.projectConfig.components && !this.output.components) return {}; - let variants: { id: string }[] = this.output.variants ?? this.projectConfig.variants ?? []; const result: ComponentsMap = {}; - if (variants.some((variant) => variant.id === 'all')) { - variants = await fetchVariants(this.meta); - } else if (variants.length === 0) { - variants = [{ id: 'base' }] - } - - for (const variant of variants) { + for (const variant of this.variants) { // map "base" to undefined, as by default export endpoint returns base variant const variantsParam = variant.id === 'base' ? undefined : [{ id: variant.id }] const params: PullQueryParams = { From af6a9375361675292ce0c8d2ded3336ff807a00e Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Fri, 12 Dec 2025 15:57:46 -0500 Subject: [PATCH 7/9] Update http request tests to handle default error --- lib/src/formatters/iosStrings.test.ts | 16 ++++++- lib/src/formatters/shared/base.test.ts | 6 +-- lib/src/formatters/shared/base.ts | 2 +- .../shared/fileTypes/IOSStringsOutputFile.ts | 1 - lib/src/http/components.ts | 6 ++- lib/src/http/projects.test.ts | 39 ++-------------- lib/src/http/projects.ts | 14 ------ lib/src/http/variants.test.ts | 46 ++----------------- lib/src/http/variants.ts | 14 ------ 9 files changed, 29 insertions(+), 115 deletions(-) diff --git a/lib/src/formatters/iosStrings.test.ts b/lib/src/formatters/iosStrings.test.ts index 131e20f..3942288 100644 --- a/lib/src/formatters/iosStrings.test.ts +++ b/lib/src/formatters/iosStrings.test.ts @@ -12,7 +12,6 @@ import fetchProjects from "../http/projects"; import fetchVariants from "../http/variants"; import IOSStringsOutputFile from "./shared/fileTypes/IOSStringsOutputFile"; -// Mock HTTP functions jest.mock("../http/textItems"); jest.mock("../http/components"); jest.mock("../http/projects"); @@ -29,9 +28,9 @@ const mockFetchVariants = fetchVariants as jest.MockedFunction< typeof fetchVariants >; +// fake test class to expose private methods // @ts-ignore class TestIOSStringsFormatter extends IOSStringsFormatter { - // Expose protected method for testing public async fetchAPIData() { return super.fetchAPIData(); } @@ -102,6 +101,10 @@ describe("IOSStringsFormatter", () => { jest.clearAllMocks(); }); + /*********************************************************** + * fetchTextItemsMap + ***********************************************************/ + describe("fetchTextItemsMap", () => { it("should fetch text items for projects and variants configured at root level", async () => { const projectConfig = createMockProjectConfig({ @@ -235,6 +238,9 @@ describe("IOSStringsFormatter", () => { }); }); + /*********************************************************** + * fetchComponentsMap + ***********************************************************/ describe("fetchComponentsMap", () => { it("should fetch components for variants configured at root level", async () => { const projectConfig = createMockProjectConfig({ @@ -337,6 +343,9 @@ describe("IOSStringsFormatter", () => { }); }); + /*********************************************************** + * fetchAPIData + ***********************************************************/ describe("fetchAPIData", () => { it("should fetchVariants and combine text items and components data", async () => { const projectConfig = createMockProjectConfig({ @@ -376,6 +385,9 @@ describe("IOSStringsFormatter", () => { }); }); + /*********************************************************** + * transformAPIData + ***********************************************************/ describe("transformAPIData", () => { it("should transform text items into IOSStringsOutputFile output files", () => { const projectConfig = createMockProjectConfig(); diff --git a/lib/src/formatters/shared/base.test.ts b/lib/src/formatters/shared/base.test.ts index 6c03f9f..83ea850 100644 --- a/lib/src/formatters/shared/base.test.ts +++ b/lib/src/formatters/shared/base.test.ts @@ -4,12 +4,9 @@ import { ProjectConfigYAML } from "../../services/projectConfig"; import { CommandMetaFlags, PullFilters } from "../../http/types"; import JSONOutputFile from "./fileTypes/JSONOutputFile"; -/** - * Test subclass that exposes protected/private methods for testing - */ +// fake test class to expose private methods // @ts-ignore class TestBaseFormatter extends BaseFormatter { - // Expose private methods for testing public generateTextItemPullFilter() { return super["generateTextItemPullFilter"](); } @@ -18,7 +15,6 @@ class TestBaseFormatter extends BaseFormatter { return super["generateComponentPullFilter"](); } - // Expose protected method for testing public generateQueryParams( requestType: "textItem" | "component", filter: PullFilters = {} diff --git a/lib/src/formatters/shared/base.ts b/lib/src/formatters/shared/base.ts index a8762a8..f94b88a 100644 --- a/lib/src/formatters/shared/base.ts +++ b/lib/src/formatters/shared/base.ts @@ -109,7 +109,7 @@ export default class BaseFormatter { return []; } - async format(): Promise { + public async format(): Promise { const data = await this.fetchAPIData(); const files = await this.transformAPIData(data); await this.writeFiles(files); diff --git a/lib/src/formatters/shared/fileTypes/IOSStringsOutputFile.ts b/lib/src/formatters/shared/fileTypes/IOSStringsOutputFile.ts index 6661ab5..6c6dd9a 100644 --- a/lib/src/formatters/shared/fileTypes/IOSStringsOutputFile.ts +++ b/lib/src/formatters/shared/fileTypes/IOSStringsOutputFile.ts @@ -1,6 +1,5 @@ import OutputFile from "./OutputFile"; -// BP: Is this MetadataType necessary? export default class IOSStringsOutputFile extends OutputFile< string, MetadataType diff --git a/lib/src/http/components.ts b/lib/src/http/components.ts index ed05e66..2d3d822 100644 --- a/lib/src/http/components.ts +++ b/lib/src/http/components.ts @@ -7,9 +7,11 @@ import { } from "./types"; import getHttpClient from "./client"; -function fetchComponentsWrapper(cb: () => Promise) { +function fetchComponentsWrapper( + performRequest: () => Promise +) { try { - return cb(); + return performRequest(); } catch (e: unknown) { if (!(e instanceof AxiosError)) { throw new Error( diff --git a/lib/src/http/projects.test.ts b/lib/src/http/projects.test.ts index 9bc91aa..0401f86 100644 --- a/lib/src/http/projects.test.ts +++ b/lib/src/http/projects.test.ts @@ -1,16 +1,13 @@ -import { AxiosError } from "axios"; import getHttpClient from "./client"; import fetchProjects from "./projects"; jest.mock("./client"); describe("fetchProjects", () => { - // Create a mock client with a mock 'get' method const mockHttpClient = { get: jest.fn(), }; - // Make getHttpClient return the mock client (getHttpClient as jest.Mock).mockReturnValue(mockHttpClient); beforeEach(() => { @@ -32,51 +29,23 @@ describe("fetchProjects", () => { }; mockHttpClient.get.mockResolvedValue(mockResponse); - const result = await fetchProjects({}); - expect(result).toEqual([...mockResponse.data]); }); it("should handle empty response", async () => { - const mockResponse = { - data: [], - }; - + const mockResponse = { data: [] }; mockHttpClient.get.mockResolvedValue(mockResponse); - const result = await fetchProjects({}); - expect(result).toEqual([]); }); - it("should handle error responses", async () => { - const mockError = new AxiosError("Request failed"); - mockError.response = { - status: 400, - data: { - message: "Invalid filter format", - }, - } as any; - - mockHttpClient.get.mockRejectedValue(mockError); - - await expect(fetchProjects({})).rejects.toThrow( - "Invalid filter format. Please check your project filters and try again." - ); - }); - - it("should handle error responses without message", async () => { - const mockError = new AxiosError("Request failed"); - mockError.response = { - status: 400, - data: {}, - } as any; - + it("should have user-friendly error response if not instance of AxiosError", async () => { + const mockError = new Error("Request failed"); mockHttpClient.get.mockRejectedValue(mockError); await expect(fetchProjects({})).rejects.toThrow( - "Invalid project filters. Please check your project filters and try again." + "Sorry! We're having trouble reaching the Ditto API. Please try again later." ); }); }); diff --git a/lib/src/http/projects.ts b/lib/src/http/projects.ts index a14e3ec..45c4d0b 100644 --- a/lib/src/http/projects.ts +++ b/lib/src/http/projects.ts @@ -15,20 +15,6 @@ export default async function fetchProjects(meta: CommandMetaFlags) { ); } - // Handle invalid filters - if (e.response?.status === 400) { - let errorMsgBase = "Invalid project filters"; - - if (e.response?.data?.message) errorMsgBase = e.response.data.message; - - throw new Error( - `${errorMsgBase}. Please check your project filters and try again.`, - { - cause: e.response?.data, - } - ); - } - throw e; } } diff --git a/lib/src/http/variants.test.ts b/lib/src/http/variants.test.ts index a34ce61..14c3fbb 100644 --- a/lib/src/http/variants.test.ts +++ b/lib/src/http/variants.test.ts @@ -1,16 +1,11 @@ -import { AxiosError } from "axios"; import getHttpClient from "./client"; import fetchVariants from "./variants"; jest.mock("./client"); describe("fetchVariants", () => { - // Create a mock client with a mock 'get' method - const mockHttpClient = { - get: jest.fn(), - }; + const mockHttpClient = { get: jest.fn() }; - // Make getHttpClient return the mock client (getHttpClient as jest.Mock).mockReturnValue(mockHttpClient); beforeEach(() => { @@ -34,9 +29,7 @@ describe("fetchVariants", () => { }; mockHttpClient.get.mockResolvedValue(mockResponse); - const result = await fetchVariants({}); - expect(result).toEqual([...mockResponse.data]); }); @@ -51,52 +44,23 @@ describe("fetchVariants", () => { }; mockHttpClient.get.mockResolvedValue(mockResponse); - const result = await fetchVariants({}); - expect(result).toEqual([...mockResponse.data]); }); it("should handle empty response", async () => { - const mockResponse = { - data: [], - }; - + const mockResponse = { data: [] }; mockHttpClient.get.mockResolvedValue(mockResponse); - const result = await fetchVariants({}); - expect(result).toEqual([]); }); - it("should handle error responses", async () => { - const mockError = new AxiosError("Request failed"); - mockError.response = { - status: 400, - data: { - message: "Invalid filter format", - }, - } as any; - + it("should have user-friendly error response if not instance of AxiosError", async () => { + const mockError = new Error("Request failed"); mockHttpClient.get.mockRejectedValue(mockError); await expect(fetchVariants({})).rejects.toThrow( - "Invalid filter format. Please check your variant filters and try again." - ); - }); - - it("should handle error responses without message", async () => { - const mockError = new AxiosError("Request failed"); - mockError.response = { - status: 400, - data: {}, - } as any; - - mockHttpClient.get.mockRejectedValue(mockError); - - await expect(fetchVariants({})).rejects.toThrow( - "Invalid variant filters. Please check your variant filters and try again." + "Sorry! We're having trouble reaching the Ditto API. Please try again later." ); }); }); - diff --git a/lib/src/http/variants.ts b/lib/src/http/variants.ts index a475021..61e486a 100644 --- a/lib/src/http/variants.ts +++ b/lib/src/http/variants.ts @@ -16,20 +16,6 @@ export default async function fetchVariants(meta: CommandMetaFlags) { ); } - // Handle invalid filters - if (e.response?.status === 400) { - let errorMsgBase = "Invalid variant filters"; - - if (e.response?.data?.message) errorMsgBase = e.response.data.message; - - throw new Error( - `${errorMsgBase}. Please check your variant filters and try again.`, - { - cause: e.response?.data, - } - ); - } - throw e; } } From ec765471e0e5d0f1eba710854dad66ceed85db04 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Fri, 12 Dec 2025 16:15:54 -0500 Subject: [PATCH 8/9] Minor: test fix --- lib/src/commands/pull.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index 216168d..62530dd 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -781,7 +781,6 @@ describe("pull command - end-to-end tests", () => { "components___base.strings", "components___variant-a.strings", "components___variant-b.strings", - "variables.json", ]); }); }); From f8f015cae8216a8a4db0f170260b5ee6105e3a2d Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Fri, 12 Dec 2025 16:48:08 -0500 Subject: [PATCH 9/9] Minor: cleanup and .gitignore of local items --- .gitignore | 1 + lib/src/commands/pull.test.ts | 2 +- lib/src/formatters/shared/base.ts | 2 -- lib/src/http/variants.ts | 1 - 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index b044dcd..73c21b5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ ditto bin/ .env coverage +.DS_Store \ No newline at end of file diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index 62530dd..bd6b456 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -777,7 +777,7 @@ describe("pull command - end-to-end tests", () => { "project-1___variant-b.strings", "project-2___base.strings", "project-2___variant-a.strings", - "project-2___variant-b.strings", // BP: Should this not be here? Do we need to check output files to see if empty variant files for a project should be shown? + "project-2___variant-b.strings", "components___base.strings", "components___variant-a.strings", "components___variant-b.strings", diff --git a/lib/src/formatters/shared/base.ts b/lib/src/formatters/shared/base.ts index f94b88a..824f3b4 100644 --- a/lib/src/formatters/shared/base.ts +++ b/lib/src/formatters/shared/base.ts @@ -36,8 +36,6 @@ export default class BaseFormatter { this.meta = meta; } - // BP: this might not be supported for export, might need to keep old implementation - //. at the json level, then use this for all the export ones private generateTextItemPullFilter() { let filters: PullFilters = { projects: this.projectConfig.projects, diff --git a/lib/src/http/variants.ts b/lib/src/http/variants.ts index 61e486a..16fd4dd 100644 --- a/lib/src/http/variants.ts +++ b/lib/src/http/variants.ts @@ -2,7 +2,6 @@ import { AxiosError } from "axios"; import { ZVariantsResponse, CommandMetaFlags } from "./types"; import getHttpClient from "./client"; -// BP: Add wrapper function to this and other HTTP requests export default async function fetchVariants(meta: CommandMetaFlags) { try { const httpClient = getHttpClient({ meta });