diff --git a/inlang/source-code/message-bundle-component/.storybook/main.js b/inlang/source-code/bundle-component/.storybook/main.js similarity index 100% rename from inlang/source-code/message-bundle-component/.storybook/main.js rename to inlang/source-code/bundle-component/.storybook/main.js diff --git a/inlang/source-code/message-bundle-component/.storybook/preview.js b/inlang/source-code/bundle-component/.storybook/preview.js similarity index 100% rename from inlang/source-code/message-bundle-component/.storybook/preview.js rename to inlang/source-code/bundle-component/.storybook/preview.js diff --git a/inlang/source-code/message-bundle-component/CHANGELOG.md b/inlang/source-code/bundle-component/CHANGELOG.md similarity index 100% rename from inlang/source-code/message-bundle-component/CHANGELOG.md rename to inlang/source-code/bundle-component/CHANGELOG.md diff --git a/inlang/source-code/message-bundle-component/README.md b/inlang/source-code/bundle-component/README.md similarity index 100% rename from inlang/source-code/message-bundle-component/README.md rename to inlang/source-code/bundle-component/README.md diff --git a/inlang/source-code/message-bundle-component/package.json b/inlang/source-code/bundle-component/package.json similarity index 82% rename from inlang/source-code/message-bundle-component/package.json rename to inlang/source-code/bundle-component/package.json index 86eeaa44c0..a05253a7dd 100644 --- a/inlang/source-code/message-bundle-component/package.json +++ b/inlang/source-code/bundle-component/package.json @@ -1,7 +1,7 @@ { - "name": "@inlang/message-bundle-component", + "name": "@inlang/bundle-component", "type": "module", - "version": "0.1.21", + "version": "0.0.1", "publishConfig": { "access": "public" }, @@ -22,10 +22,9 @@ "author": "", "license": "ISC", "devDependencies": { - "@inlang/marketplace-registry": "workspace:^", "@inlang/marketplace-manifest": "workspace:^", + "@inlang/marketplace-registry": "workspace:^", "@inlang/message-lint-rule": "workspace:^", - "@inlang/sdk": "workspace:^", "@nx/storybook": "^18.0.4", "@storybook/addon-essentials": "^7.6.16", "@storybook/addon-links": "^7.6.16", @@ -33,19 +32,22 @@ "@storybook/web-components": "^7.6.16", "@storybook/web-components-vite": "^7.6.16", "@types/chroma-js": "^2.4.4", + "@vitest/coverage-v8": "^0.33.0", "esbuild": "^0.20.0", "lit": "^3.1.2", "react": "^18.2.0", "react-dom": "^18.2.0", "storybook": "^7.6.16", - "vitest": "0.33.0", - "@vitest/coverage-v8": "^0.33.0" + "vitest": "0.33.0" }, "dependencies": { - "@inlang/sdk": "workspace:^", + "@inlang/sdk-v2": "workspace:^", + "@lexical/plain-text": "^0.16.1", + "@lexical/utils": "^0.16.1", "@lit/task": "^1.0.0", "@shoelace-style/shoelace": "2.14.0", + "@sinclair/typebox": "0.31.28", "chroma-js": "^2.4.2", - "@sinclair/typebox": "0.31.28" + "lexical": "^0.16.1" } } diff --git a/inlang/source-code/bundle-component/src/helper/crud/input/create.test.ts b/inlang/source-code/bundle-component/src/helper/crud/input/create.test.ts new file mode 100644 index 0000000000..55ced403de --- /dev/null +++ b/inlang/source-code/bundle-component/src/helper/crud/input/create.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest" +import createInput from "./create.js" +import { createBundle } from "@inlang/sdk-v2" + +describe("createInput", () => { + it("Should create an input declarations", () => { + const messageBundle = createBundle({ + id: "bundleId", + messages: [ + { + bundleId: "bundleId", + id: "testId1", + locale: "en", + selectors: [], + declarations: [ + { + type: "input", + name: "count", + value: { + type: "expression", + arg: { + type: "variable", + name: "count", + }, + }, + }, + ], + variants: [], + }, + { + bundleId: "bundleId", + id: "testId2", + locale: "de", + selectors: [], + declarations: [ + { + type: "input", + name: "count", + value: { + type: "expression", + arg: { + type: "variable", + name: "count", + }, + }, + }, + ], + variants: [], + }, + ], + }) + + createInput({ messageBundle, inputName: "user_name" }) + + expect(messageBundle.messages[0]!.declarations.length).toBe(2) + expect(messageBundle.messages[0]!.declarations[1]!.name).toBe("user_name") + }) +}) diff --git a/inlang/source-code/bundle-component/src/helper/crud/input/create.ts b/inlang/source-code/bundle-component/src/helper/crud/input/create.ts new file mode 100644 index 0000000000..3cef7be86c --- /dev/null +++ b/inlang/source-code/bundle-component/src/helper/crud/input/create.ts @@ -0,0 +1,36 @@ +import type { Declaration, NestedBundle } from "@inlang/sdk-v2" + +/** + * Creates an input in all messages of a message bundle. + * The function mutates the message bundle. + * @param props.messageBundle The message bundle to create the input in. + * @param props.inputName The name of the input to create. + * + * @example + * createInput({ messageBundle, inputName: "myInput" }) + */ + +const createInput = (props: { messageBundle: NestedBundle; inputName: string }) => { + for (const message of props.messageBundle.messages) { + if ( + message.declarations.some((declaration: Declaration) => declaration.name === props.inputName) + ) { + console.error("Input with name already exists") + return + } else { + message.declarations.push({ + type: "input", + name: props.inputName, + value: { + type: "expression", + arg: { + type: "variable", + name: props.inputName, + }, + }, + }) + } + } +} + +export default createInput diff --git a/inlang/source-code/bundle-component/src/helper/crud/input/delete.test.ts b/inlang/source-code/bundle-component/src/helper/crud/input/delete.test.ts new file mode 100644 index 0000000000..bb175303a7 --- /dev/null +++ b/inlang/source-code/bundle-component/src/helper/crud/input/delete.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest" +import deleteInput from "./delete.js" +import { createBundle } from "@inlang/sdk-v2" + +describe("deleteInput", () => { + it("Should delete a specific input declarations", () => { + const messageBundle = createBundle({ + id: "bundleId", + messages: [ + { + bundleId: "bundleId", + id: "testId", + locale: "en", + selectors: [], + declarations: [ + { + type: "input", + name: "count", + value: { + type: "expression", + arg: { + type: "variable", + name: "count", + }, + }, + }, + { + type: "input", + name: "user_name", + value: { + type: "expression", + arg: { + type: "variable", + name: "user_name", + }, + }, + }, + ], + variants: [], + }, + { + bundleId: "bundleId", + id: "testId", + locale: "en", + selectors: [], + declarations: [ + { + type: "input", + name: "count", + value: { + type: "expression", + arg: { + type: "variable", + name: "count", + }, + }, + }, + { + type: "input", + name: "user_name", + value: { + type: "expression", + arg: { + type: "variable", + name: "user_name", + }, + }, + }, + ], + variants: [], + }, + ], + }) + const inputs = deleteInput({ + messageBundle, + input: messageBundle.messages[0]!.declarations[0]!, + }) + expect(messageBundle.messages[0]!.declarations.length).toBe(1) + expect(messageBundle.messages[1]!.declarations.length).toBe(1) + expect(messageBundle.messages[0]!.declarations[0]!.name).toBe("user_name") + expect(messageBundle.messages[1]!.declarations[0]!.name).toBe("user_name") + }) +}) diff --git a/inlang/source-code/bundle-component/src/helper/crud/input/delete.ts b/inlang/source-code/bundle-component/src/helper/crud/input/delete.ts new file mode 100644 index 0000000000..007e59789a --- /dev/null +++ b/inlang/source-code/bundle-component/src/helper/crud/input/delete.ts @@ -0,0 +1,21 @@ +import type { Declaration, NestedBundle } from "@inlang/sdk-v2" + +/** + * Deletes an input from all messages of a message bundle. + * The function mutates the message bundle. + * @param props.messageBundle The message bundle to delete the input from. + * @param props.input The input to delete. + */ + +const deleteInput = (props: { messageBundle: NestedBundle; input: Declaration }) => { + for (const message of props.messageBundle.messages) { + const index = message.declarations.findIndex((d: any) => d.name === props.input.name) + if (index === -1) { + continue + } else { + message.declarations.splice(index, 1) + } + } +} + +export default deleteInput diff --git a/inlang/source-code/bundle-component/src/helper/crud/input/get.test.ts b/inlang/source-code/bundle-component/src/helper/crud/input/get.test.ts new file mode 100644 index 0000000000..d8462501d3 --- /dev/null +++ b/inlang/source-code/bundle-component/src/helper/crud/input/get.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest" +import getInput from "./get.js" +import { createBundle } from "@inlang/sdk-v2" + +describe("getInput", () => { + it("Should return all found input declarations", () => { + const messageBundle = createBundle({ + id: "bundleId", + messages: [ + { + bundleId: "bundleId", + id: "testId", + locale: "en", + selectors: [], + declarations: [ + { + type: "input", + name: "count", + value: { + type: "expression", + arg: { + type: "variable", + name: "count", + }, + }, + }, + ], + variants: [], + }, + { + bundleId: "bundleId", + id: "testId", + locale: "en", + selectors: [], + declarations: [ + { + type: "input", + name: "count", + value: { + type: "expression", + arg: { + type: "variable", + name: "count", + }, + }, + }, + ], + variants: [], + }, + { + bundleId: "bundleId", + id: "testId", + locale: "en", + selectors: [], + declarations: [], + variants: [], + }, + ], + }) + const inputs = getInput({ messageBundle }) + expect(inputs.length).toBe(1) + expect(inputs[0]!.name).toBe("count") + }) +}) diff --git a/inlang/source-code/bundle-component/src/helper/crud/input/get.ts b/inlang/source-code/bundle-component/src/helper/crud/input/get.ts new file mode 100644 index 0000000000..e9e507bce0 --- /dev/null +++ b/inlang/source-code/bundle-component/src/helper/crud/input/get.ts @@ -0,0 +1,21 @@ +import type { Declaration, NestedBundle } from "@inlang/sdk-v2" + +/** + * Gets all inputs from a message bundle. + * @param props.messageBundle The message bundle to get the inputs from. + * @returns All inputs from the message bundle. + */ + +const getInputs = (props: { messageBundle: NestedBundle }): Declaration[] => { + const inputs: Declaration[] = [] + for (const message of props.messageBundle.messages) { + for (const declaration of message.declarations) { + if (declaration.type === "input" && !inputs.some((d) => d.name === declaration.name)) { + inputs.push(declaration) + } + } + } + return inputs +} + +export default getInputs diff --git a/inlang/source-code/bundle-component/src/helper/crud/pattern/patternToString.test.ts b/inlang/source-code/bundle-component/src/helper/crud/pattern/patternToString.test.ts new file mode 100644 index 0000000000..a0e465e767 --- /dev/null +++ b/inlang/source-code/bundle-component/src/helper/crud/pattern/patternToString.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest" +import patternToString from "./patternToString.js" +import type { Pattern } from "@inlang/sdk-v2" + +describe("patternToString", () => { + it("Should transform pattern to string", () => { + const pattern: Pattern = [ + { + type: "text", + value: "Hello, ", + }, + { + type: "expression", + arg: { + type: "variable", + name: "name", + }, + }, + { + type: "text", + value: "!", + }, + ] + + const text = patternToString({ pattern }) + + const correspondingText = "Hello, {{name}}!" + + expect(text).toStrictEqual(correspondingText) + }) +}) diff --git a/inlang/source-code/bundle-component/src/helper/crud/pattern/patternToString.ts b/inlang/source-code/bundle-component/src/helper/crud/pattern/patternToString.ts new file mode 100644 index 0000000000..c5e3555a9a --- /dev/null +++ b/inlang/source-code/bundle-component/src/helper/crud/pattern/patternToString.ts @@ -0,0 +1,32 @@ +import type { Pattern } from "@inlang/sdk-v2" + +/** + * MVP version of the function + * + * Converts a pattern to a string. + * @param props.pattern The pattern to convert to a string. + * @returns The pattern as a string. + * + * @example + * patternToString({ pattern: [{ value: "Hello" }, { type: "expression", arg: { type: "variable", name: "name" } }] }) -> "Hello {{name}}" + */ + +const patternToString = (props: { pattern: Pattern }): string => { + if (!props.pattern) { + return "" + } + return props.pattern + .map((p) => { + if ("value" in p) { + return p.value + // @ts-ignore + } else if (p.type === "expression" && p.arg.type === "variable") { + // @ts-ignore + return `{{${p.arg.name}}}` + } + return "" + }) + .join("") +} + +export default patternToString diff --git a/inlang/source-code/bundle-component/src/helper/crud/pattern/stringToPattern.test.ts b/inlang/source-code/bundle-component/src/helper/crud/pattern/stringToPattern.test.ts new file mode 100644 index 0000000000..741f5b82e9 --- /dev/null +++ b/inlang/source-code/bundle-component/src/helper/crud/pattern/stringToPattern.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest" +import stringToPattern from "./stringToPattern.js" +import type { Pattern } from "@inlang/sdk-v2" + +describe("stringToPattern", () => { + it("Should transform string to pattern", () => { + const text = "Hello, {{name}}!" + + const pattern = stringToPattern({ text }) + + const correspondingPattern: Pattern = [ + { + type: "text", + value: "Hello, ", + }, + { + type: "expression", + arg: { + type: "variable", + name: "name", + }, + }, + { + type: "text", + value: "!", + }, + ] + expect(pattern).toStrictEqual(correspondingPattern) + }) +}) diff --git a/inlang/source-code/bundle-component/src/helper/crud/pattern/stringToPattern.ts b/inlang/source-code/bundle-component/src/helper/crud/pattern/stringToPattern.ts new file mode 100644 index 0000000000..2cd94e6953 --- /dev/null +++ b/inlang/source-code/bundle-component/src/helper/crud/pattern/stringToPattern.ts @@ -0,0 +1,52 @@ +import type { Pattern } from "@inlang/sdk-v2" + +/** + * MVP version of the function + * + * Converts a string to a pattern. + * @param props.text The text to convert to a pattern. + * @returns The pattern of the text. + * + * @example + * stringToPattern({ text: "Hello {{name}}" }) -> [{ value: "Hello" }, { type: "expression", arg: { type: "variable", name: "name" } }] + */ + +const stringToPattern = (props: { text: string }): Pattern => { + const pattern: Pattern = [] + const regex = /{{(.*?)}}/g + let lastIndex = 0 + let match + + while ((match = regex.exec(props.text)) !== null) { + // Add text node for text before the variable + if (match.index > lastIndex) { + pattern.push({ + type: "text", + value: props.text.slice(lastIndex, match.index), + }) + } + // Add variable node + if (match[1]) { + pattern.push({ + type: "expression", + arg: { + type: "variable", + name: match[1], + }, + }) + lastIndex = regex.lastIndex + } + } + + // Add remaining text node after the last variable + if (lastIndex < props.text.length) { + pattern.push({ + type: "text", + value: props.text.slice(lastIndex), + }) + } + + return pattern +} + +export default stringToPattern diff --git a/inlang/source-code/bundle-component/src/helper/crud/selector/add.test.ts b/inlang/source-code/bundle-component/src/helper/crud/selector/add.test.ts new file mode 100644 index 0000000000..d2efdc6faa --- /dev/null +++ b/inlang/source-code/bundle-component/src/helper/crud/selector/add.test.ts @@ -0,0 +1,38 @@ +import { type Expression, type NestedMessage } from "@inlang/sdk-v2" +import { describe, expect, it } from "vitest" +import addSelector from "./add.js" + +describe("addSelector", () => { + it("Should add selector", () => { + const message: NestedMessage = { + bundleId: "testId", + id: "testId", + locale: "en", + selectors: [ + { + type: "expression", + arg: { + type: "variable", + name: "count", + }, + }, + ], + declarations: [], + variants: [] + } + + const newSelector = { + type: "expression", + arg: { + type: "variable", + name: "name", + }, + } + + addSelector({ message, selector: newSelector as Expression }) + + expect(message.selectors.length).toBe(2) + // @ts-ignore + expect(message.selectors[1]!.arg.name).toBe("name") + }) +}) diff --git a/inlang/source-code/bundle-component/src/helper/crud/selector/add.ts b/inlang/source-code/bundle-component/src/helper/crud/selector/add.ts new file mode 100644 index 0000000000..4cf1f5b6ea --- /dev/null +++ b/inlang/source-code/bundle-component/src/helper/crud/selector/add.ts @@ -0,0 +1,30 @@ +import { createVariant, type Expression, type NestedMessage } from "@inlang/sdk-v2" + +/** + * Adds a selector to a message. + * The function mutates the message. + * @param props.message The message to add the selector to. + * @param props.selector The selector to add. + * + * @example + * addSelector({ message, selector: { type: "expression", arg: { type: "variable", name: "mySelector" } } }) + */ + +const addSelector = (props: { message: NestedMessage; selector: Expression }) => { + props.message.selectors.push(props.selector) + if (props.message.variants.length !== 0) { + for (const variant of props.message.variants) { + variant.match[props.selector.arg.name] = "*" + } + } else { + props.message.variants.push( + createVariant({ + messageId: props.message.id, + match: { [props.selector.arg.name]: "*" }, + text: "", + }) + ) + } +} + +export default addSelector diff --git a/inlang/source-code/bundle-component/src/helper/crud/selector/delete.test.ts b/inlang/source-code/bundle-component/src/helper/crud/selector/delete.test.ts new file mode 100644 index 0000000000..1fdaef87a7 --- /dev/null +++ b/inlang/source-code/bundle-component/src/helper/crud/selector/delete.test.ts @@ -0,0 +1,53 @@ +import { type NestedMessage } from "@inlang/sdk-v2" +import { describe, expect, it } from "vitest" +import deleteSelector from "./delete.js" + +describe("deleteSelector", () => { + it("Should delete selector", () => { + const message: NestedMessage = { + bundleId: "bundleTestId", + id: "testId", + locale: "en", + selectors: [ + { + type: "expression", + arg: { + type: "variable", + name: "count", + }, + }, + { + type: "expression", + arg: { + type: "variable", + name: "name", + }, + }, + ], + declarations: [], + variants: [ + { + id: "variantId", + messageId: "testId", + match: { + count: "1", + name: "John", + }, + pattern: [ + { + type: "text", + value: "Hello ", + }, + ], + }, + ], + } + + deleteSelector({ message, index: 0 }) + + expect(message.selectors.length).toBe(1) + // @ts-ignore + expect(message.selectors[0]!.arg.name).toBe("name") + expect(Object.keys(message.variants[0]!.match).length).toBe(1) + }) +}) diff --git a/inlang/source-code/bundle-component/src/helper/crud/selector/delete.ts b/inlang/source-code/bundle-component/src/helper/crud/selector/delete.ts new file mode 100644 index 0000000000..f7e4ed7f8d --- /dev/null +++ b/inlang/source-code/bundle-component/src/helper/crud/selector/delete.ts @@ -0,0 +1,29 @@ +import type { NestedMessage } from "@inlang/sdk-v2" + +/** + * Deletes a selector from a message. + * The function mutates the message. + * @param props.message The message to delete the selector from. + * @param props.index The index of the selector to delete. + * + * @example + * deleteSelector({ message, index: 0 }) + */ + +const deleteSelector = (props: { message: NestedMessage; index: number }) => { + const selectorName = props.message.selectors[props.index]!.arg.name + if (selectorName) { + props.message.selectors.splice(props.index, 1) + for (const variant of props.message.variants) { + for (const name in variant.match) { + if (name === selectorName) { + delete variant.match[name] + } + } + } + } else { + console.error("Index is not pointing on a selector") + } +} + +export default deleteSelector diff --git a/inlang/source-code/bundle-component/src/helper/crud/variant/delete.test.ts b/inlang/source-code/bundle-component/src/helper/crud/variant/delete.test.ts new file mode 100644 index 0000000000..daac826b5a --- /dev/null +++ b/inlang/source-code/bundle-component/src/helper/crud/variant/delete.test.ts @@ -0,0 +1,25 @@ +import { type NestedMessage, createVariant } from "@inlang/sdk-v2" +import { describe, expect, it } from "vitest" +import deleteVariant from "./delete.js" + +describe("deleteVariant", () => { + it("Should delete variant", () => { + const messageId = "testId" + const message: NestedMessage = { + bundleId: "testBundleId", + id: messageId, + locale: "en", + selectors: [], + declarations: [], + variants: [ + createVariant({ messageId, id: "a", match: { count: "*" } }), + createVariant({ messageId, id: "b", match: { count: "one" } }), + ], + } + + deleteVariant({ message, variant: message.variants[1]! }) + + expect(message.variants.length).toBe(1) + expect(message.variants[0]!.id).toBe("a") + }) +}) diff --git a/inlang/source-code/bundle-component/src/helper/crud/variant/delete.ts b/inlang/source-code/bundle-component/src/helper/crud/variant/delete.ts new file mode 100644 index 0000000000..46e38ad6c4 --- /dev/null +++ b/inlang/source-code/bundle-component/src/helper/crud/variant/delete.ts @@ -0,0 +1,20 @@ +import type { NestedMessage, Variant } from "@inlang/sdk-v2" + +/** + * Deletes a variant from a message. + * The function mutates the message. + * @param props.message The message to delete the variant from. + * @param props.variant The variant to delete. + */ + +const deleteVariant = (props: { message: NestedMessage; variant: Variant }) => { + // index from array where the variant is located + const index = props.message.variants.findIndex((variant) => variant.id === props.variant.id) + + if (index > -1) { + // Delete existing variant + props.message.variants.splice(index, 1) + } +} + +export default deleteVariant diff --git a/inlang/source-code/bundle-component/src/helper/crud/variant/isCatchAll.test.ts b/inlang/source-code/bundle-component/src/helper/crud/variant/isCatchAll.test.ts new file mode 100644 index 0000000000..af32dfbcfd --- /dev/null +++ b/inlang/source-code/bundle-component/src/helper/crud/variant/isCatchAll.test.ts @@ -0,0 +1,33 @@ +import { createVariant } from "@inlang/sdk-v2" +import { describe, expect, it } from "vitest" +import variantIsCatchAll from "./isCatchAll.js" + +describe("isCatchAll", () => { + it("Should return true if variant is catchAll", () => { + expect( + variantIsCatchAll({ variant: createVariant({ messageId: "testId", match: { count: "*" } }) }) + ).toBe(true) + expect( + variantIsCatchAll({ + variant: createVariant({ messageId: "testId", match: { count: "*", count2: "*" } }), + }) + ).toBe(true) + }) + it("Should return false if variant is not catchAll", () => { + expect( + variantIsCatchAll({ + variant: createVariant({ messageId: "testId", match: { count: "one" } }), + }) + ).toBe(false) + expect( + variantIsCatchAll({ + variant: createVariant({ messageId: "testId", match: { count: "*", count2: "one" } }), + }) + ).toBe(false) + expect( + variantIsCatchAll({ + variant: createVariant({ messageId: "testId", match: { count: "one", count2: "*" } }), + }) + ).toBe(false) + }) +}) diff --git a/inlang/source-code/bundle-component/src/helper/crud/variant/isCatchAll.ts b/inlang/source-code/bundle-component/src/helper/crud/variant/isCatchAll.ts new file mode 100644 index 0000000000..23c19fee66 --- /dev/null +++ b/inlang/source-code/bundle-component/src/helper/crud/variant/isCatchAll.ts @@ -0,0 +1,16 @@ +import type { Variant } from "@inlang/sdk-v2" + +/** + * Returns true if the variant has a catch-all match. + * @param props.variant The variant to check. + * @returns True if the variant has a catch-all match. + */ +const variantIsCatchAll = (props: { variant: Variant }): boolean => { + if (Object.values(props.variant.match).filter((match) => match !== "*").length === 0) { + return true + } else { + return false + } +} + +export default variantIsCatchAll diff --git a/inlang/source-code/bundle-component/src/helper/crud/variant/sortAll.test.ts b/inlang/source-code/bundle-component/src/helper/crud/variant/sortAll.test.ts new file mode 100644 index 0000000000..abdb4bb08e --- /dev/null +++ b/inlang/source-code/bundle-component/src/helper/crud/variant/sortAll.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from "vitest" +import sortAllVariants from "./sortAll.js" +import { createVariant, type Expression, type Variant } from "@inlang/sdk-v2" + +describe("sortAllVariants", () => { + it("should sort variants based on matches (desc-alphabetically)", () => { + const variants: Variant[] = [ + createVariant({ messageId: "testId", id: "1", match: { letter: "a" } }), + createVariant({ messageId: "testId", id: "2", match: { letter: "b" } }), + createVariant({ messageId: "testId", id: "3", match: { letter: "c" } }), + ] + const ignoreVariantIds: string[] = [] + const selectors: Expression[] = [ + { + type: "expression", + arg: { + type: "variable", + name: "letter", + }, + annotation: { + type: "function", + name: "string", + options: [], + }, + }, + ] + + const sortedVariants = sortAllVariants({ variants, ignoreVariantIds, selectors }) + + expect(sortedVariants).toEqual([ + createVariant({ messageId: "testId", id: "3", match: { letter: "c" } }), + createVariant({ messageId: "testId", id: "2", match: { letter: "b" } }), + createVariant({ messageId: "testId", id: "1", match: { letter: "a" } }), + ]) + }) + + it("should sort variants based on matches (desc-alphabetically) complex", () => { + const variants: Variant[] = [ + createVariant({ + messageId: "testId", + id: "1", + match: { letter1: "a", letter2: "b", letter3: "c" }, + }), + createVariant({ + messageId: "testId", + id: "2", + match: { letter1: "b", letter2: "b", letter3: "b" }, + }), + createVariant({ + messageId: "testId", + id: "3", + match: { letter1: "b", letter2: "a", letter3: "a" }, + }), + ] + const ignoreVariantIds: string[] = [] + const selectors: Expression[] = [ + { + type: "expression", + arg: { + type: "variable", + name: "letter1", + }, + annotation: { + type: "function", + name: "string", + options: [], + }, + }, + { + type: "expression", + arg: { + type: "variable", + name: "letter2", + }, + annotation: { + type: "function", + name: "string", + options: [], + }, + }, + { + type: "expression", + arg: { + type: "variable", + name: "letter3", + }, + annotation: { + type: "function", + name: "string", + options: [], + }, + }, + ] + + const sortedVariants = sortAllVariants({ variants, ignoreVariantIds, selectors }) + + expect(sortedVariants).toEqual([ + createVariant({ + messageId: "testId", + id: "2", + match: { letter1: "b", letter2: "b", letter3: "b" }, + }), + createVariant({ + messageId: "testId", + id: "3", + match: { letter1: "b", letter2: "a", letter3: "a" }, + }), + createVariant({ + messageId: "testId", + id: "1", + match: { letter1: "a", letter2: "b", letter3: "c" }, + }), + ]) + }) + + it("should sort variants based on matches (desc-alphabetically) with ignoreVariantIds", () => { + const variants: Variant[] = [ + createVariant({ + messageId: "testId", + id: "1", + match: { letter1: "a", letter2: "b", letter3: "c" }, + }), + createVariant({ + messageId: "testId", + id: "2", + match: { letter1: "b", letter2: "b", letter3: "b" }, + }), + createVariant({ + messageId: "testId", + id: "3", + match: { letter1: "b", letter2: "a", letter3: "a" }, + }), + createVariant({ + messageId: "testId", + id: "4", + match: { letter1: "z", letter2: "z", letter3: "z" }, + }), + ] + const ignoreVariantIds: string[] = ["4"] + const selectors: Expression[] = [ + { + type: "expression", + arg: { + type: "variable", + name: "letter1", + }, + annotation: { + type: "function", + name: "string", + options: [], + }, + }, + { + type: "expression", + arg: { + type: "variable", + name: "letter2", + }, + annotation: { + type: "function", + name: "string", + options: [], + }, + }, + { + type: "expression", + arg: { + type: "variable", + name: "letter3", + }, + annotation: { + type: "function", + name: "string", + options: [], + }, + }, + ] + + const sortedVariants = sortAllVariants({ variants, ignoreVariantIds, selectors }) + + expect(sortedVariants).toEqual([ + createVariant({ + messageId: "testId", + id: "2", + match: { letter1: "b", letter2: "b", letter3: "b" }, + }), + createVariant({ + messageId: "testId", + id: "3", + match: { letter1: "b", letter2: "a", letter3: "a" }, + }), + createVariant({ + messageId: "testId", + id: "1", + match: { letter1: "a", letter2: "b", letter3: "c" }, + }), + createVariant({ + messageId: "testId", + id: "4", + match: { letter1: "z", letter2: "z", letter3: "z" }, + }), + ]) + }) + + it("should handle empty variants array", () => { + const variants: Variant[] = [] + const ignoreVariantIds: string[] = [] + const selectors: Expression[] = [] + + const sortedVariants = sortAllVariants({ variants, ignoreVariantIds, selectors }) + + expect(sortedVariants).toEqual([]) + }) +}) diff --git a/inlang/source-code/bundle-component/src/helper/crud/variant/sortAll.ts b/inlang/source-code/bundle-component/src/helper/crud/variant/sortAll.ts new file mode 100644 index 0000000000..194e315d3f --- /dev/null +++ b/inlang/source-code/bundle-component/src/helper/crud/variant/sortAll.ts @@ -0,0 +1,43 @@ +import { type Expression, type Variant } from "@inlang/sdk-v2" + +/** + * Sort all variants by their match values. + * The function does not mutate the variants array. + * @param props.variants The variants to sort. The variants will be sorted in place. + * @param props.ignoreVariantIds The ids of the variants to ignore in the sorting. These variants will be placed at the end of the list. + * @returns The sorted variants. + */ +const sortAllVariants = (props: { + variants: Variant[] + ignoreVariantIds: string[] + selectors: Expression[] +}): Variant[] => { + const sortedVariants: Variant[] = structuredClone(props.variants) + sortedVariants.sort((a, b) => { + return compareValues(a, b, 0, props.ignoreVariantIds, props.selectors) + }) + return sortedVariants +} + +const compareValues = ( + a: Variant, + b: Variant, + index: number, + ignoreVariantIds: string[], + selectors: Expression[] +): number => { + const selectorName = selectors[index]?.arg.name + if (!selectorName) return 0 + + if (a.match[selectorName] && b.match[selectorName]) { + if (ignoreVariantIds.includes(a.id)) return 1 + if (a.match[selectorName]! < b.match[selectorName]!) return 1 + if (a.match[selectorName]! > b.match[selectorName]!) return -1 + } + + if (Object.values(a.match).length === index + 1) return 0 + if (index > 10) return 0 + return compareValues(a, b, index + 1, ignoreVariantIds, selectors) +} + +export default sortAllVariants diff --git a/inlang/source-code/bundle-component/src/helper/crud/variant/updateMatch.test.ts b/inlang/source-code/bundle-component/src/helper/crud/variant/updateMatch.test.ts new file mode 100644 index 0000000000..990b2b3c72 --- /dev/null +++ b/inlang/source-code/bundle-component/src/helper/crud/variant/updateMatch.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest" +import updateMatch from "./updateMatch.js" +import { createVariant, type Variant } from "@inlang/sdk-v2" + +describe("updateMatch", () => { + it("should update the value at the specified match index", () => { + const variant: Variant = createVariant({ + messageId: "testId", + id: "test-id", + match: { selector1: "apple", selector2: "banana", selector3: "cherry" }, + }) + const selectorName = "selector2" + const value = "orange" + + updateMatch({ variant, selectorName, value }) + + expect(variant.match[selectorName]).toBe(value) + expect(Object.keys(variant.match).length).toBe(3) + }) + + it("should not update the value if match name doesn't exist", () => { + const variant: Variant = createVariant({ + messageId: "testId", + id: "test-id", + match: { selector1: "apple", selector2: "banana", selector3: "cherry" }, + }) + const selectorName = "selector4" + const value = "orange" + + updateMatch({ variant, selectorName, value }) + + expect(variant.match[selectorName]).toBeUndefined() + expect(Object.keys(variant.match).length).toBe(3) + }) +}) diff --git a/inlang/source-code/bundle-component/src/helper/crud/variant/updateMatch.ts b/inlang/source-code/bundle-component/src/helper/crud/variant/updateMatch.ts new file mode 100644 index 0000000000..143e752fd5 --- /dev/null +++ b/inlang/source-code/bundle-component/src/helper/crud/variant/updateMatch.ts @@ -0,0 +1,19 @@ +import type { Expression, Variant } from "@inlang/sdk-v2" + +/** + * Update the match at the specified index with the new value. + * The function mutates the variant. + * @param props.variant The variant to update. + * @param props.matchIndex The index of the match to update. + * @param props.value The new value to set at the match index. + */ + +const updateMatch = (props: { variant: Variant; selectorName: string; value: string }) => { + // if matchName is not in variant, return + if (!props.variant.match[props.selectorName]) return + + // update the match with value (mutates variant) + props.variant.match[props.selectorName] = props.value +} + +export default updateMatch diff --git a/inlang/source-code/bundle-component/src/helper/crud/variant/upsert.test.ts b/inlang/source-code/bundle-component/src/helper/crud/variant/upsert.test.ts new file mode 100644 index 0000000000..fbe7e1bcae --- /dev/null +++ b/inlang/source-code/bundle-component/src/helper/crud/variant/upsert.test.ts @@ -0,0 +1,84 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { createMessage, createBundle, createVariant } from "@inlang/sdk-v2" +import { describe, expect, it } from "vitest" +import upsertVariant from "./upsert.js" + +describe("upsertVariant", () => { + it("Should update existing variant", () => { + const bundle = createBundle({ + id: "bundle-id", + messages: [ + { + bundleId: "bundle-id", + id: "test_message_id", + locale: "en", + declarations: [], + selectors: [], + variants: [ + createVariant({ + messageId: "testId", + id: "test_upsertVariant_id", + match: { count: "*" }, + text: "Hello World", + }), + ], + }, + ], + }) + + expect(bundle.messages).toHaveLength(1) + expect(bundle.messages[0]?.variants[0]?.pattern).toStrictEqual([ + { type: "text", value: "Hello World" }, + ]) + + upsertVariant({ + message: bundle.messages[0]!, + variant: { + messageId: bundle.messages[0]!.id, + id: "test_upsertVariant_id", + match: { count: "*" }, + pattern: [{ type: "text", value: "Hello Universe" }], + }, + }) + + expect(bundle.messages[0]?.variants).toHaveLength(1) + expect(bundle.messages[0]?.variants[0]?.pattern).toStrictEqual([ + { type: "text", value: "Hello Universe" }, + ]) + }) + + it("Should create a new variant", () => { + const bundle = createBundle({ + id: "bundle-id", + messages: [ + createMessage({ + bundleId: "bundle-id", + locale: "en", + text: "Hello World", + match: { count: "*" }, + }), + ], + }) + + expect(bundle.messages).toHaveLength(1) + expect(bundle.messages[0]?.variants[0]?.pattern).toStrictEqual([ + { type: "text", value: "Hello World" }, + ]) + + upsertVariant({ + message: bundle.messages[0]!, + variant: { + messageId: bundle.messages[0]!.id, + id: "test_upsertVariant_id", + match: { count: "one" }, + pattern: [{ type: "text", value: "Hello Universe" }], + }, + }) + + expect(bundle.messages[0]?.variants).toHaveLength(2) + // it's 0 because it's sorted alphabetically + expect(bundle.messages[0]?.variants[1]?.pattern).toStrictEqual([ + { type: "text", value: "Hello Universe" }, + ]) + }) +}) diff --git a/inlang/source-code/bundle-component/src/helper/crud/variant/upsert.ts b/inlang/source-code/bundle-component/src/helper/crud/variant/upsert.ts new file mode 100644 index 0000000000..92e822282c --- /dev/null +++ b/inlang/source-code/bundle-component/src/helper/crud/variant/upsert.ts @@ -0,0 +1,33 @@ +import type { NestedMessage, Variant } from "@inlang/sdk-v2" + +/** + * Upsert a variant into a message. If a variant with the same match already exists, it will be updated, otherwise a new variant will be added. + * The function mutates message. + * @param props.message The message to upsert the variant into. + * @param props.variant The variant to upsert. + */ + +const upsertVariant = (props: { + message: NestedMessage + variant: Variant +}): Variant | undefined => { + const existingVariant = props.message.variants.find((variant) => variant.id === props.variant.id) + + if (existingVariant) { + // Update existing variant + // check if pattern is different + if (JSON.stringify(existingVariant.pattern) !== JSON.stringify(props.variant.pattern)) { + existingVariant.pattern = props.variant.pattern + return existingVariant + } else { + // pattern is the same + return + } + } else { + // Insert new variant + props.message.variants.push(props.variant) + return props.variant + } +} + +export default upsertVariant diff --git a/inlang/source-code/message-bundle-component/src/helper/overridePrimitiveColors.test.ts b/inlang/source-code/bundle-component/src/helper/overridePrimitiveColors.test.ts similarity index 100% rename from inlang/source-code/message-bundle-component/src/helper/overridePrimitiveColors.test.ts rename to inlang/source-code/bundle-component/src/helper/overridePrimitiveColors.test.ts diff --git a/inlang/source-code/message-bundle-component/src/helper/overridePrimitiveColors.ts b/inlang/source-code/bundle-component/src/helper/overridePrimitiveColors.ts similarity index 98% rename from inlang/source-code/message-bundle-component/src/helper/overridePrimitiveColors.ts rename to inlang/source-code/bundle-component/src/helper/overridePrimitiveColors.ts index 0a7e8e60ae..474506badf 100644 --- a/inlang/source-code/message-bundle-component/src/helper/overridePrimitiveColors.ts +++ b/inlang/source-code/bundle-component/src/helper/overridePrimitiveColors.ts @@ -22,7 +22,7 @@ const overridePrimitiveColors = () => { const appendCSSProperties = ( colorShades: Record, primitive: string, - element: HTMLElement + element: Element ) => { let textContent = Object.entries(colorShades) .map(([index, shade]) => `--sl-color-${primitive}-${index}: ${shade} !important;`) diff --git a/inlang/source-code/message-bundle-component/src/helper/simplifyBundle.ts b/inlang/source-code/bundle-component/src/helper/simplifyBundle.ts similarity index 82% rename from inlang/source-code/message-bundle-component/src/helper/simplifyBundle.ts rename to inlang/source-code/bundle-component/src/helper/simplifyBundle.ts index 8b1458d9d7..54dd074d0a 100644 --- a/inlang/source-code/message-bundle-component/src/helper/simplifyBundle.ts +++ b/inlang/source-code/bundle-component/src/helper/simplifyBundle.ts @@ -1,6 +1,6 @@ -import type { MessageBundle } from "@inlang/sdk/v2" +import type { NestedBundle } from "@inlang/sdk-v2" -export const simplifyBundle = (bundle: MessageBundle) => { +export const simplifyBundle = (bundle: NestedBundle) => { // all patterns should become a single text pattern that is randomized for (const message of bundle.messages) { for (const variant of message.variants) { diff --git a/inlang/source-code/bundle-component/src/index.ts b/inlang/source-code/bundle-component/src/index.ts new file mode 100644 index 0000000000..51a2ba1eac --- /dev/null +++ b/inlang/source-code/bundle-component/src/index.ts @@ -0,0 +1 @@ +export { default as InlangBundle } from "./stories/inlang-bundle.js" diff --git a/inlang/source-code/bundle-component/src/inlang-bundle.mdx b/inlang/source-code/bundle-component/src/inlang-bundle.mdx new file mode 100644 index 0000000000..e10e28cf75 --- /dev/null +++ b/inlang/source-code/bundle-component/src/inlang-bundle.mdx @@ -0,0 +1,4 @@ +import { Meta, Primary, Controls, Story, Canvas, ArgTypes, Source } from '@storybook/blocks'; +import * as InlangBundle from './stories/inlang-bundle.stories'; + +# Inlang Bundle \ No newline at end of file diff --git a/inlang/source-code/bundle-component/src/mock/lint.ts b/inlang/source-code/bundle-component/src/mock/lint.ts new file mode 100644 index 0000000000..5fb4e07384 --- /dev/null +++ b/inlang/source-code/bundle-component/src/mock/lint.ts @@ -0,0 +1,88 @@ +import type { LintReport } from "@inlang/sdk-v2" +import // createMockBundleLintReport, +// createMockMessageLintReport, +// createMockVariantLintReport, +"@inlang/sdk-v2" + +// export const mockMessageLintReports: LintReport[] = [ +// createMockMessageLintReport({ +// ruleId: "messageBundleLintRule.inlang.missingReference", +// messageBundleId: "mock_bundle_human_id", +// messageId: "mock_message_id_en", +// body: "The bundle `mock_bundle_human_id` is missing the reference message for the locale `en`", +// }), +// createMockMessageLintReport({ +// ruleId: "messageBundleLintRule.inlang.missingReference", +// messageBundleId: "mock_bundle_human_id", +// messageId: "mock_message_id_en", +// body: "The bundle `mock_bundle_human_id` is missing the reference message for the locale `en`", +// level: "warning", +// }), +// ] + +// export const mockVariantLintReports: LintReport[] = [ +// createMockVariantLintReport({ +// ruleId: "messageBundleLintRule.inlang.missingMessage", +// messageBundleId: "mock_bundle_human_id", +// messageId: "mock_message_id_de", +// variantId: "mock_variant_id_de_one", +// body: "Variant test for `de` to check if can be rendered correctly", +// }), +// ] + +export const mockMessageLintReports = [ + { + ruleId: "message.inlang.missingMessage", + type: "message", + typeId: "mock_message_id_de", + body: "The bundle `mock_bundle_human_id` is missing the reference message for the locale `en`", + level: "error", + }, +] + +export const mockVariantLintReports = [ + { + ruleId: "variant.inlang.uppercaseVariant", + type: "variant", + typeId: "mock_variant_id_de_one", + body: "There is a variant that contains the term opral, which is not written in uppercase", + level: "error", + }, +] + +export const mockBundleLintReports = [ + { + ruleId: "bundle.inlang.aliasIncorrect", + type: "bundle", + typeId: "mock_bundle_human_id", + body: "The alias is incorrect", + level: "error", + }, +] + +export const mockInstalledLintRules = [ + { + id: "message.inlang.missingMessage", + displayName: "Missing Message", + description: "Reports when a message is missing in a message bundle", + module: + "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-missing-translation@latest/dist/index.js", + level: "error", + }, + { + id: "variant.inlang.uppercaseVariant", + displayName: "Uppercase Variant", + description: "Reports when opral is not written in uppercase", + module: + "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-missing-translation@latest/dist/index.js", + level: "error", + }, + { + id: "bundle.inlang.aliasIncorrect", + displayName: "Alias Incorrect", + description: "Reports when the alias is incorrect", + module: + "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-missing-translation@latest/dist/index.js", + level: "error", + }, +] diff --git a/inlang/source-code/bundle-component/src/mock/messageBundle.ts b/inlang/source-code/bundle-component/src/mock/messageBundle.ts new file mode 100644 index 0000000000..1e8a268cc9 --- /dev/null +++ b/inlang/source-code/bundle-component/src/mock/messageBundle.ts @@ -0,0 +1,41 @@ +import type { NestedBundle } from "@inlang/sdk-v2" + +export const bundleWithoutSelectors: NestedBundle = { + id: "message-bundle-id", + messages: [ + { + bundleId: "message-bundle-id", + id: "message-id-en", + locale: "en", + selectors: [], + declarations: [], + variants: [ + { + messageId: "message-id-en", + id: "variant-id-en-*", + match: {}, + pattern: [{ type: "text", value: "{count} new messages" }], + }, + ], + }, + { + bundleId: "message-bundle-id", + id: "message-id-de", + locale: "de", + selectors: [], + declarations: [], + variants: [ + { + messageId: "message-id-de", + id: "variant-id-de-*", + match: {}, + pattern: [{ type: "text", value: "{count} neue Nachrichten" }], + }, + ], + }, + ], + alias: {}, + // default: "frontend_button_text", + // ios: "frontendButtonText", + // }, +} diff --git a/inlang/source-code/bundle-component/src/mock/settings.ts b/inlang/source-code/bundle-component/src/mock/settings.ts new file mode 100644 index 0000000000..549fa96e33 --- /dev/null +++ b/inlang/source-code/bundle-component/src/mock/settings.ts @@ -0,0 +1,42 @@ +import type { ProjectSettings2 } from "@inlang/sdk-v2" + +export const mockSettings: ProjectSettings2 = { + $schema: "https://inlang.com/schema/project-settings", + baseLocale: "en", + locales: ["en", "de", "nl"], + lintConfig: [ + { + ruleId: "messageBundleLintRule.inlang.identicalPattern", + level: "error", + }, + ], + modules: [ + "https://cdn.jsdelivr.net/npm/@inlang/plugin-i18next@4/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-empty-pattern@latest/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-identical-pattern@latest/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-missing-translation@latest/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-without-source@latest/dist/index.js", + ], + "plugin.inlang.i18next": { + pathPattern: { + brain: "./frontend/public/locales/{languageTag}/brain.json", + chat: "./frontend/public/locales/{languageTag}/chat.json", + config: "./frontend/public/locales/{languageTag}/config.json", + contact: "./frontend/public/locales/{languageTag}/contact.json", + deleteOrUnsubscribeFormBrain: + "./frontend/public/locales/{languageTag}/deleteOrUnsubscribeFormBrain.json", + explore: "./frontend/public/locales/{languageTag}/explore.json", + external_api_definition: + "./frontend/public/locales/{languageTag}/external_api_definition.json", + home: "./frontend/public/locales/{languageTag}/home.json", + invitation: "./frontend/public/locales/{languageTag}/invitation.json", + knowledge: "./frontend/public/locales/{languageTag}/knowledge.json", + login: "./frontend/public/locales/{languageTag}/login.json", + logout: "./frontend/public/locales/{languageTag}/logout.json", + monetization: "./frontend/public/locales/{languageTag}/monetization.json", + translation: "./frontend/public/locales/{languageTag}/translation.json", + upload: "./frontend/public/locales/{languageTag}/upload.json", + user: "./frontend/public/locales/{languageTag}/user.json", + }, + }, +} diff --git a/inlang/source-code/bundle-component/src/stories/actions/inlang-bundle-action.ts b/inlang/source-code/bundle-component/src/stories/actions/inlang-bundle-action.ts new file mode 100644 index 0000000000..8e10ab38ee --- /dev/null +++ b/inlang/source-code/bundle-component/src/stories/actions/inlang-bundle-action.ts @@ -0,0 +1,47 @@ +import { LitElement, css, html } from "lit" +import { customElement, property } from "lit/decorators.js" +import { baseStyling } from "../../styling/base.js" + +import SlButton from "@shoelace-style/shoelace/dist/components/button/button.component.js" + +// in case an app defines it's own set of shoelace components, prevent double registering +if (!customElements.get("sl-button")) customElements.define("sl-button", SlButton) + +@customElement("inlang-bundle-action") +export default class InlangBundleAction extends LitElement { + static override styles = [ + baseStyling, + css` + div { + box-sizing: border-box; + font-size: 13px; + } + sl-button::part(base) { + color: var(--sl-input-color); + background-color: var(--sl-input-background-color); + border: 1px solid var(--sl-input-border-color); + border-radius: 4px; + font-size: 13px; + } + sl-button::part(base):hover { + color: var(--sl-input-color-hover); + background-color: var(--sl-input-background-color-hover); + border: 1px solid var(--sl-input-border-color-hover); + } + `, + ] + + //props + @property() + actionTitle: string | undefined + + override render() { + return html`${this.actionTitle}` + } +} + +declare global { + interface HTMLElementTagNameMap { + "inlang-bundle-action": InlangBundleAction + } +} diff --git a/inlang/source-code/bundle-component/src/stories/actions/inlang-variant-action.ts b/inlang/source-code/bundle-component/src/stories/actions/inlang-variant-action.ts new file mode 100644 index 0000000000..b43547ee32 --- /dev/null +++ b/inlang/source-code/bundle-component/src/stories/actions/inlang-variant-action.ts @@ -0,0 +1,54 @@ +import { LitElement, css, html } from "lit" +import { customElement, property } from "lit/decorators.js" +import { baseStyling } from "../../styling/base.js" + +import SlButton from "@shoelace-style/shoelace/dist/components/button/button.component.js" +import SlTooltip from "@shoelace-style/shoelace/dist/components/tooltip/tooltip.component.js" + +// in case an app defines it's own set of shoelace components, prevent double registering +if (!customElements.get("sl-button")) customElements.define("sl-button", SlButton) +if (!customElements.get("sl-tooltip")) customElements.define("sl-tooltip", SlTooltip) + +@customElement("inlang-variant-action") +export default class InlangVariantAction extends LitElement { + static override styles = [ + baseStyling, + css` + div { + box-sizing: border-box; + font-size: 13px; + } + sl-button::part(base) { + color: var(--sl-input-color); + background-color: var(--sl-input-background-color); + border: 1px solid var(--sl-input-border-color); + border-radius: 4px; + font-size: 13px; + } + sl-button::part(base):hover { + color: var(--sl-input-color-hover); + background-color: var(--sl-input-background-color-hover); + border: 1px solid var(--sl-input-border-color-hover); + } + `, + ] + + //props + @property() + actionTitle: string | undefined + + @property() + tooltip: string | undefined + + override render() { + return html`${this.actionTitle}` + } +} + +declare global { + interface HTMLElementTagNameMap { + "inlang-variant-action": InlangVariantAction + } +} diff --git a/inlang/source-code/bundle-component/src/stories/inlang-add-input.ts b/inlang/source-code/bundle-component/src/stories/inlang-add-input.ts new file mode 100644 index 0000000000..4d75f9c960 --- /dev/null +++ b/inlang/source-code/bundle-component/src/stories/inlang-add-input.ts @@ -0,0 +1,157 @@ +import { LitElement, css, html } from "lit" +import { customElement, property, state } from "lit/decorators.js" + +import SlDropdown from "@shoelace-style/shoelace/dist/components/dropdown/dropdown.component.js" +import SlInput from "@shoelace-style/shoelace/dist/components/input/input.component.js" + +@customElement("inlang-add-input") +export default class InlangAddInput extends LitElement { + static override styles = [ + css` + .button-wrapper { + height: 44px; + display: flex; + align-items: center; + justify-content: center; + } + .dropdown-container { + font-size: 13px; + width: 240px; + background-color: var(--sl-panel-background-color); + border: 1px solid var(--sl-input-border-color); + padding: 12px; + border-radius: 6px; + display: flex; + flex-direction: column; + gap: 16px; + } + .dropdown-item { + display: flex; + flex-direction: column; + gap: 2px; + } + .dropdown-header { + display: flex; + justify-content: space-between; + align-items: center; + color: var(--sl-input-color); + font-size: 12px; + } + .dropdown-title { + font-size: 12px; + font-weight: 500; + margin: 6px 0; + } + .help-text { + display: flex; + gap: 8px; + color: var(--sl-input-help-text-color); + } + .help-text p { + flex: 1; + margin: 0; + font-size: 12px; + line-height: 1.5; + } + .actions { + width: 100%; + display: flex; + flex-direction: column; + gap: 4px; + } + `, + ] + + //props + @property() + addInput: (inputName: string) => void = () => {} + + //state + @state() + private _newInput: string | undefined + + override async firstUpdated() { + await this.updateComplete + this._newInput = "" + } + + override render() { + return html` + { + const dropdown = this.shadowRoot?.querySelector("sl-dropdown") + if (dropdown) { + if (e.target === dropdown) { + const input: SlInput | undefined | null = this.shadowRoot?.querySelector("sl-input") + setTimeout(() => { + if (input) input.focus() + }) + } + } + }} + > +
+ +
+ +
+ ` + } +} + +declare global { + interface HTMLElementTagNameMap { + "inlang-add-input": InlangAddInput + } +} diff --git a/inlang/source-code/bundle-component/src/stories/inlang-bundle-header.ts b/inlang/source-code/bundle-component/src/stories/inlang-bundle-header.ts new file mode 100644 index 0000000000..7923550151 --- /dev/null +++ b/inlang/source-code/bundle-component/src/stories/inlang-bundle-header.ts @@ -0,0 +1,312 @@ +import type { + Declaration, + InstalledLintRule, + LanguageTag, + LintReport, + Message, + NestedBundle, + ProjectSettings2, + Variant, +} from "@inlang/sdk-v2" +import { LitElement, css, html } from "lit" +import { customElement, property, state } from "lit/decorators.js" +import getInputs from "../helper/crud/input/get.js" +import deleteInput from "../helper/crud/input/delete.js" + +@customElement("inlang-bundle-header") +export default class InlangBundleHeader extends LitElement { + static override styles = [ + css` + div { + box-sizing: border-box; + font-size: 13px; + } + .header { + display: flex; + justify-content: space-between; + background-color: var(--sl-color-neutral-300); + padding: 0 12px; + min-height: 44px; + } + .header-left { + font-weight: 600; + display: flex; + align-items: center; + gap: 16px; + min-height: 44px; + color: var(--sl-input-color); + } + .header-right { + display: flex; + align-items: center; + gap: 12px; + min-height: 44px; + } + .separator { + height: 20px; + width: 1px; + background-color: var(--sl-input-border-color-hover); + opacity: 0.7; + border-radius: 1px; + } + .slotted-menu-wrapper { + display: flex; + flex-direction: column; + } + .inputs-wrapper { + display: flex; + align-items: center; + min-height: 44px; + gap: 8px; + color: var(--sl-input-color); + } + .inputs { + display: flex; + align-items: center; + min-height: 44px; + gap: 1px; + } + .input-tag::part(base) { + height: 28px; + font-weight: 500; + cursor: pointer; + } + .text-button::part(base) { + background-color: transparent; + border: 1px solid transparent; + } + .text-button::part(base):hover { + background-color: var(--sl-panel-border-color); + border: 1px solid transparent; + color: var(--sl-input-color-hover); + } + .alias-wrapper { + display: flex; + align-items: center; + gap: 8px; + } + .alias { + font-weight: 400; + color: var(--sl-input-placeholder-color); + } + .alias-counter { + height: 20px; + width: 24px; + font-weight: 500; + font-size: 11px; + color: var(--sl-input-color-hover); + padding: 4px; + border-radius: 4px; + background-color: var(--sl-input-background-color-hover); + display: flex; + align-items: center; + justify-content: center; + } + sl-button::part(base) { + background-color: var(--sl-input-background-color); + color: var(--sl-input-color); + border: 1px solid var(--sl-input-border-color); + font-size: 13px; + } + sl-button::part(base):hover { + background-color: var(--sl-input-background-color-hover); + color: var(--sl-input-color-hover); + border: 1px solid var(--sl-input-border-color-hover); + } + sl-menu-item::part(label) { + font-size: 14px; + padding-left: 12px; + } + sl-menu-item::part(base) { + color: var(--sl-input-color); + } + sl-menu-item::part(base):hover { + background-color: var(--sl-input-background-color-hover); + } + sl-menu-item::part(checked-icon) { + display: none; + } + .selector:hover { + background-color: var(--sl-input-background-color-hover); + } + `, + ] + + @property() + bundle: NestedBundle | undefined + + @property() + settings: ProjectSettings2 | undefined + + @property() + bundleValidationReports: Array | undefined + + @property({ type: Array }) + installedLintRules: InstalledLintRule[] | undefined + + @property() + fixLint: (lintReport: LintReport, fix: LintReport["fixes"][0]["title"]) => void = () => {} + + @property() + addInput: (input: Declaration) => void = () => {} + + @property() + triggerSave: () => void = () => {} + + @property() + triggerRefresh: () => void = () => {} + + @state() + private _hasActions: boolean = false + + dispatchOnUpdateMessage(message: Message, variants: Variant[]) { + const onUpdateMessage = new CustomEvent("update-message", { + bubbles: true, + composed: true, + detail: { + argument: { + message, + variants, + }, + }, + }) + this.dispatchEvent(onUpdateMessage) + } + + private _inputs = (): Declaration[] | undefined => { + return this.bundle ? getInputs({ messageBundle: this.bundle }) : undefined + } + + override async firstUpdated() { + await this.updateComplete + this._hasActions = this.querySelector("[slot=bundle-action]") !== null + } + + override render() { + return html` +
+
+ # ${this.bundle?.id} + ${this.bundle?.alias && Object.keys(this.bundle.alias).length > 0 + ? html`
+ Alias: ${this.bundle?.alias?.default} + ${Object.keys(this.bundle.alias).length > 1 + ? html`
+ +${Object.keys(this.bundle.alias).length - 1} +
` + : ``} +
` + : ``} +
+ +
+ ${this._inputs() && this._inputs()!.length > 0 + ? html`
+ Inputs: +
+ ${this._inputs()?.map( + (input) => + html`${input.name} + { + deleteInput({ messageBundle: this.bundle!, input }) + this.requestUpdate() + for (const message of this.bundle!.messages) { + this.dispatchOnUpdateMessage(message, []) + } + }} + >Delete + + ` + )} + { + this.addInput(x) + this.requestUpdate() + }} + > + + + + + +
+
` + : html`
+ { + this.addInput(x) + this.requestUpdate() + }} + > + + + Input + + +
`} + ${this._hasActions + ? html`
+ + + + + + + ` + : ``} + ${this.bundleValidationReports && this.bundleValidationReports.length > 0 + ? html`` + : ``} +
+
+ ` + } +} + +declare global { + interface HTMLElementTagNameMap { + "inlang-bundle-header": InlangBundleHeader + } +} diff --git a/inlang/source-code/bundle-component/src/stories/inlang-bundle-root.ts b/inlang/source-code/bundle-component/src/stories/inlang-bundle-root.ts new file mode 100644 index 0000000000..f951c26029 --- /dev/null +++ b/inlang/source-code/bundle-component/src/stories/inlang-bundle-root.ts @@ -0,0 +1,33 @@ +import { LitElement, css, html } from "lit" +import { customElement, property } from "lit/decorators.js" +import { baseStyling } from "../styling/base.js" + +@customElement("inlang-bundle-root") +export default class InlangBundleRoot extends LitElement { + static override styles = [ + baseStyling, + css` + div { + box-sizing: border-box; + font-size: 13px; + } + .messages-container { + width: 100%; + margin-bottom: 16px; + } + `, + ] + + override render() { + return html`
+ + +
` + } +} + +declare global { + interface HTMLElementTagNameMap { + "inlang-bundle-root": InlangBundleRoot + } +} diff --git a/inlang/source-code/bundle-component/src/stories/inlang-bundle.stories.ts b/inlang/source-code/bundle-component/src/stories/inlang-bundle.stories.ts new file mode 100644 index 0000000000..b657e76b00 --- /dev/null +++ b/inlang/source-code/bundle-component/src/stories/inlang-bundle.stories.ts @@ -0,0 +1,175 @@ +import "./inlang-bundle.ts" +import "./testing/reactiveWrapper.ts" +import type { Meta, StoryObj } from "@storybook/web-components" +import { html } from "lit" +// import { +// mockInstalledLintRules, +// mockMessageLintReports, +// mockVariantLintReports, +// } from "../mock/lint.ts" +import { mockSettings } from "../mock/settings.ts" +import { bundleWithoutSelectors } from "../mock/messageBundle.ts" +import { pluralBundle } from "@inlang/sdk-v2" + +import "./actions/inlang-bundle-action.ts" +import { + mockBundleLintReports, + mockInstalledLintRules, + mockMessageLintReports, + mockVariantLintReports, +} from "../mock/lint.ts" + +const meta: Meta = { + component: "inlang-bundle", + title: "Public/inlang-bundle", + argTypes: { + bundle: { + control: { type: "object" }, + description: "Type MessageBundle: see sdk v2", + }, + settings: { + control: { type: "object" }, + description: "Type ProjectSettings2: see sdk v2", + }, + installedLintRules: { + control: { type: "Array" }, + description: + "Optional: Type InstalledLintRule[]: see sdk v2. If defined the reports will be shown with more meta data.", + }, + filteredLocales: { + control: { type: "Array" }, + description: + "Optional: Type LanguageTag[]. Pass it in when you want to filter the locales of the bundle. If not passed, all locales will be shown.", + }, + }, +} + +export default meta + +export const Simple: StoryObj = { + render: () => { + return html` console.info("changeBundle", data.detail.argument)} + @machine-translate=${(data: any) => + console.info("machine translate", JSON.stringify(data.detail.argument))} + @fix-lint=${(data: any) => console.info("fixLint", data.detail.argument)} + > + ` + }, +} + +export const Complex: StoryObj = { + render: () => { + return html` console.info("updateBundle", data.detail.argument)} + @insert-message=${(data: any) => console.info("insertMessage", data.detail.argument)} + @update-message=${(data: any) => console.info("updateMessage", data.detail.argument)} + @delete-message=${(data: any) => console.info("deleteMessage", data.detail.argument)} + @insert-variant=${(data: any) => console.info("insertVariant", data.detail.argument)} + @update-variant=${(data: any) => console.info("updateVariant", data.detail.argument)} + @delete-variant=${(data: any) => console.info("deleteVariant", data.detail.argument)} + @fix-lint=${(data: any) => console.info("fixLint", data.detail.argument)} + > + console.log("Share")} + > + console.log("Edit alias")} + > + ` + }, +} + +export const Styled: StoryObj = { + render: () => + html` + + + + console.info("changeMessageBundle", data.detail.argument)} + @fix-lint=${(data: any) => console.info("fixLint", data.detail.argument)} + > + console.log("Share")} + > + console.log("Edit alias")} + > + + `, +} + +export const ReactiveLints: StoryObj = { + render: () => { + return html` + ` + }, +} diff --git a/inlang/source-code/bundle-component/src/stories/inlang-bundle.ts b/inlang/source-code/bundle-component/src/stories/inlang-bundle.ts new file mode 100644 index 0000000000..984e886a12 --- /dev/null +++ b/inlang/source-code/bundle-component/src/stories/inlang-bundle.ts @@ -0,0 +1,465 @@ +import { html, LitElement } from "lit" +import { customElement, property, state } from "lit/decorators.js" +import overridePrimitiveColors from "../helper/overridePrimitiveColors.js" +import { + type Bundle, + type Message, + type Pattern, + type LanguageTag, + type LintReport, + type ProjectSettings2, + type Declaration, + createVariant, + createMessage, + type Variant, + type Expression, +} from "@inlang/sdk-v2" +import type { InstalledLintRule, NestedBundle, NestedMessage } from "@inlang/sdk-v2" + +//internal components +import "./inlang-bundle-root.js" +import "./inlang-bundle-header.js" +import "./inlang-message.js" +import "./inlang-variant.js" +import "./pattern-editor/inlang-pattern-editor.js" +import "./actions/inlang-variant-action.js" + +//shoelace components +import SlTag from "@shoelace-style/shoelace/dist/components/tag/tag.component.js" +import SlInput from "@shoelace-style/shoelace/dist/components/input/input.component.js" +import SlButton from "@shoelace-style/shoelace/dist/components/button/button.component.js" +import SlTooltip from "@shoelace-style/shoelace/dist/components/tooltip/tooltip.component.js" +import SlDropdown from "@shoelace-style/shoelace/dist/components/dropdown/dropdown.component.js" +import SlMenu from "@shoelace-style/shoelace/dist/components/menu/menu.component.js" +import SlMenuItem from "@shoelace-style/shoelace/dist/components/menu-item/menu-item.component.js" +import SlSelect from "@shoelace-style/shoelace/dist/components/select/select.component.js" +import SlOption from "@shoelace-style/shoelace/dist/components/option/option.component.js" + +// in case an app defines it's own set of shoelace components, prevent double registering +if (!customElements.get("sl-tag")) customElements.define("sl-tag", SlTag) +if (!customElements.get("sl-input")) customElements.define("sl-input", SlInput) +if (!customElements.get("sl-button")) customElements.define("sl-button", SlButton) +if (!customElements.get("sl-tooltip")) customElements.define("sl-tooltip", SlTooltip) +if (!customElements.get("sl-dropdown")) customElements.define("sl-dropdown", SlDropdown) +if (!customElements.get("sl-menu")) customElements.define("sl-menu", SlMenu) +if (!customElements.get("sl-menu-item")) customElements.define("sl-menu-item", SlMenuItem) +if (!customElements.get("sl-select")) customElements.define("sl-select", SlSelect) +if (!customElements.get("sl-option")) customElements.define("sl-option", SlOption) + +//helpers +import getInputs from "../helper/crud/input/get.js" +import createInput from "../helper/crud/input/create.js" +import upsertVariant from "../helper/crud/variant/upsert.js" +import patternToString from "../helper/crud/pattern/patternToString.js" +import sortAllVariants from "../helper/crud/variant/sortAll.js" +import InlangBundleAction from "./actions/inlang-bundle-action.js" + +@customElement("inlang-bundle") +export default class InlangBundle extends LitElement { + //props + @property({ type: Object }) + bundle: NestedBundle | undefined + + @property({ type: Object }) + settings: ProjectSettings2 | undefined + + @property({ type: Array }) + filteredLocales: LanguageTag[] | undefined + + @property({ type: Array }) + installedLintRules: InstalledLintRule[] | undefined + + @property({ type: Array }) + bundleValidationReports: Array | undefined + + @property({ type: Array }) + messageValidationReports: Array | undefined + + @property({ type: Array }) + variantValidationReports: Array | undefined + + //disable shadow root -> because of contenteditable selection API + override createRenderRoot() { + return this + } + + // events + dispatchOnUpdateVariant(variant: Variant) { + const onUpdateVariant = new CustomEvent("update-variant", { + bubbles: true, + composed: true, + detail: { + argument: { + variant, + }, + }, + }) + this.dispatchEvent(onUpdateVariant) + } + + dispatchOnInsertVariant(variant: Variant) { + const onInsertVariant = new CustomEvent("insert-variant", { + bubbles: true, + composed: true, + detail: { + argument: { + variant, + }, + }, + }) + this.dispatchEvent(onInsertVariant) + } + + dispatchOnInsertMessage(message: Message, variants: Variant[]) { + const onInsertMessage = new CustomEvent("insert-message", { + bubbles: true, + composed: true, + detail: { + argument: { + message, + variants, + }, + }, + }) + this.dispatchEvent(onInsertMessage) + } + + dispatchOnUpdateMessage(message: Message, variants: Variant[]) { + const onUpdateMessage = new CustomEvent("update-message", { + bubbles: true, + composed: true, + detail: { + argument: { + message, + variants, + }, + }, + }) + this.dispatchEvent(onUpdateMessage) + } + + dispatchOnFixLint(lintReport: LintReport, fix: LintReport["fixes"][0]["title"]) { + const onFixLint = new CustomEvent("fix-lint", { + bubbles: true, + composed: true, + detail: { + argument: { + lintReport, + fix, + }, + }, + }) + this.dispatchEvent(onFixLint) + } + + dispatchOnMachineTranslate(messageId?: string, variantId?: string) { + const onMachineTranslate = new CustomEvent("machine-translate", { + bubbles: true, + composed: true, + detail: { + argument: { + messageId, + variantId, + }, + }, + }) + this.dispatchEvent(onMachineTranslate) + } + + // internal variables/states + @state() + private _bundle: NestedBundle | undefined + + @state() + private _freshlyAddedVariants: string[] = [] + + @state() + private _bundleActions: Element[] = [] + + //functions + private _triggerSave = () => { + if (this._bundle) { + //this.dispatchOnChangeMessageBundle(this._bundle) + } + } + + private _addMessage = (message: NestedMessage) => { + if (this._bundle) { + this._bundle.messages.push(message) + } + this._triggerSave() + this._triggerRefresh() + } + + private _addInput = (name: string) => { + if (this._bundle) { + createInput({ messageBundle: this._bundle, inputName: name }) + } + for (const message of this._bundle?.messages || []) { + this.dispatchOnUpdateMessage(message, []) + } + this.requestUpdate() + } + + private _triggerRefresh = () => { + this.requestUpdate() + } + + private _resetFreshlyAddedVariants = (newArray: string[]) => { + this._freshlyAddedVariants = newArray + } + + private _fixLint = (lintReport: LintReport, fix: LintReport["fixes"][0]["title"]) => { + this.dispatchOnFixLint(lintReport, fix) + } + + private _refLocale = (): LanguageTag | undefined => { + return this.settings?.baseLocale + } + + private _filteredLocales = (): LanguageTag[] | undefined => { + if (!this.filteredLocales) return this.settings?.locales + if (this.filteredLocales && this.filteredLocales.length === 0) return this.filteredLocales + return this.filteredLocales + } + + private _locales = (): LanguageTag[] | undefined => { + return this._filteredLocales() || undefined + } + + private _inputs = (): Declaration[] | undefined => { + const _refLanguageTag = this._refLocale() + return _refLanguageTag && this._bundle ? getInputs({ messageBundle: this._bundle }) : undefined + } + + private _getBundleActions = (): Element[] => { + // @ts-ignore -- @NilsJacobsen check why this produces a ts error + return [...this.children] + .filter((child) => child instanceof InlangBundleAction) + .map((child) => { + child.setAttribute("slot", "bundle-action") + const style = document.createElement("style") + style.textContent = ` + sl-menu-item::part(label) { + font-size: 14px; + padding-left: 12px; + } + sl-menu-item::part(base) { + color: var(--sl-input-color); + } + sl-menu-item::part(base):hover { + background-color: var(--sl-input-background-color-hover); + } + sl-menu-item::part(checked-icon) { + display: none; + } + ` + child.shadowRoot?.appendChild(style) + return child + }) + } + + // fill message with empty message if message is undefined to fix layout shift (will not be committed) + private _fillMessage = ( + bundleId: string, + message: NestedMessage | undefined, + locale: LanguageTag + ): NestedMessage => { + if (message) { + if (message.variants.length === 0) { + message.variants.push(createVariant({ messageId: message.id, match: {} })) + } + return message + } else { + return createMessage({ bundleId, locale: locale, text: "" }) + } + } + + private _handleUpdatePattern = ( + message: NestedMessage | undefined, + variant: Variant | undefined, + newPattern: Pattern, + locale: LanguageTag + ) => { + if (variant) { + if (!message) { + throw new Error("a variant cant exist without a message") + } + + // update existing variant + const newVariant = { ...variant, pattern: newPattern } + const upsertedVariant = upsertVariant({ + message: message!, + variant: newVariant, + }) + if (upsertedVariant) { + this.dispatchOnUpdateVariant(upsertedVariant) + } + } else { + if (message) { + const newVariant = { + ...createVariant({ messageId: message!.id, match: {} }), + pattern: newPattern, + } + + // A message object exists -> just add the new variant + upsertVariant({ + message: message!, + variant: newVariant, + }) + this.dispatchOnInsertVariant(newVariant) + } else { + const messageWithoutVariant = createMessage({ + bundleId: this.bundle!.id, + locale: locale, + text: "test", + }) + + const newVariant = { + ...createVariant({ messageId: messageWithoutVariant!.id, match: {} }), + pattern: newPattern, + } + + // A message object does not exist yet -> create one that also contains the new variant + const newMessage = { + ...messageWithoutVariant, + selectors: [], + declarations: [], + locale: locale, + variants: [newVariant], + } + this._addMessage(newMessage) + this.dispatchOnInsertMessage(newMessage, [newVariant]) + this.dispatchOnInsertVariant(newVariant) + } + } + this.requestUpdate() + } + + // hooks + override updated(changedProperties: any) { + // works like useEffect + // In order to not mutate object references, we need to clone the object + // When the messageBundle prop changes, we update the internal state + if (changedProperties.has("bundle")) { + this._bundle = structuredClone(this.bundle) + this._bundleActions = this._getBundleActions() + } + } + + override connectedCallback() { + super.connectedCallback() + this._bundle = structuredClone(this.bundle) + } + + override async firstUpdated() { + await this.updateComplete + // override primitive colors to match the design system + overridePrimitiveColors() + this._bundle = structuredClone(this.bundle) + } + + override render() { + return html` + + + ${this._bundleActions.map((action) => { + return html`${action}` + })} + +
+ ${this._locales() && + this._locales()?.map((locale) => { + const message = this._bundle?.messages.find((message) => message.locale === locale) + const _messageValidationReports = this.messageValidationReports?.filter( + (report: any) => report.typeId === message?.id + ) + // TODO SDK-v2 lint reports + return html` + ${sortAllVariants({ + variants: this._fillMessage( + this._bundle?.id ? this._bundle?.id : "WHATTT", // TODO SDK-v2 @nils check how we deal with undefined + structuredClone(message), + locale + ).variants, + ignoreVariantIds: this._freshlyAddedVariants, + selectors: message?.selectors || [], + })?.map((fakevariant) => { + const variant = message?.variants.find((v) => v.id === fakevariant.id) + const _variantValidationReports: Array | undefined = + this.variantValidationReports?.filter( + (report: any) => report.typeId === variant?.id + ) + return html` + { + this._handleUpdatePattern(message, variant, event.detail.argument, locale) + }} + > + ${patternToString({ pattern: variant?.pattern || [] }) === "" + ? html` { + this.dispatchOnMachineTranslate(message?.id, variant?.id) + }} + >` + : ``} + ` + })} + ` + })} +
+
+ ` + } +} + +declare global { + interface HTMLElementTagNameMap { + "inlang-bundle": InlangBundle + } +} diff --git a/inlang/source-code/bundle-component/src/stories/inlang-lint-report-tip.ts b/inlang/source-code/bundle-component/src/stories/inlang-lint-report-tip.ts new file mode 100644 index 0000000000..dc9609b8c9 --- /dev/null +++ b/inlang/source-code/bundle-component/src/stories/inlang-lint-report-tip.ts @@ -0,0 +1,209 @@ +import { LitElement, css, html } from "lit" +import { customElement, property } from "lit/decorators.js" + +import type { InstalledLintRule, LintReport } from "@inlang/sdk-v2" + +@customElement("inlang-lint-report-tip") +export default class InlangLintReportTip extends LitElement { + static override styles = [ + css` + .lint-report-tip { + height: 29px; + width: 29px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + cursor: pointer; + color: var(--sl-input-color); + } + .lint-report-tip.error { + color: var(--sl-color-danger-700); + } + .lint-report-tip.warning { + color: var(--sl-color-warning-600); + } + .lint-report-tip:hover { + background-color: var(--sl-input-background-color-hover); + } + .lint-report-tip.warning:hover { + color: var(--sl-color-warning-700); + } + .dropdown-container { + font-size: 13px; + width: 240px; + background-color: var(--sl-panel-background-color); + border: 1px solid var(--sl-panel-border-color); + border-radius: 6px; + display: flex; + flex-direction: column; + } + .dropdown-item { + display: flex; + flex-direction: row; + gap: 12px; + padding: 8px 12px; + padding-bottom: 10px; + border-top: 1px solid var(--sl-input-border-color); + } + .dropdown-item:first-child { + border-top: none; + } + .report-icon { + height: 29px; + width: 29px; + color: var(--sl-input-color); + display: flex; + align-items: center; + justify-content: center; + } + .report-icon.error { + color: var(--sl-color-danger-700); + } + .report-icon.warning { + color: var(--sl-color-warning-500); + } + .report-content { + display: flex; + flex-direction: column; + gap: 4px; + } + .report-title { + padding-top: 2px; + font-size: 12px; + font-weight: 500; + color: var(--sl-input-color); + } + .report-body { + font-size: 12px; + color: var(--sl-input-help-text-color); + line-break: anywhere; + } + .report-fixes { + display: flex; + flex-direction: column; + gap: 4px; + padding-top: 4px; + } + .fix-button { + width: 100%; + } + .fix-button::part(base) { + color: var(--sl-input-color); + background-color: var(--sl-input-background-color); + border: 1px solid var(--sl-input-border-color); + } + .fix-button::part(base):hover { + color: var(--sl-input-color-hover); + background-color: var(--sl-input-background-color-hover); + border: 1px solid var(--sl-input-border-color-hover); + } + p { + margin: 0; + } + `, + ] + + //props + @property() + lintReports: LintReport[] | undefined + + @property() + installedLintRules: InstalledLintRule[] | undefined + + @property() + fixLint: (lintReport: LintReport, fix: LintReport["fixes"][0]["title"]) => void = () => {} + + //functions + private _getLintReportLevelClass = () => { + if (this.lintReports?.some((report) => report.level === "error")) { + return "error" + } + if (this.lintReports?.some((report) => report.level === "warning")) { + return "warning" + } + return "" + } + + private _getLintDisplayName = (ruleId: string) => { + const rule = this.installedLintRules?.find((rule) => rule.id === ruleId) + + if (typeof rule?.displayName === "string") { + return rule.displayName + } else if (typeof rule === "object") { + return (rule?.displayName as { en: string }).en + } else { + return ruleId.split(".")[2] + } + } + + override render() { + return html` { + //console.log(e) + }} + > +
+ + + +
+ +
` + } +} + +declare global { + interface HTMLElementTagNameMap { + "inlang-lint-report-tip": InlangLintReportTip + } +} diff --git a/inlang/source-code/bundle-component/src/stories/inlang-message.ts b/inlang/source-code/bundle-component/src/stories/inlang-message.ts new file mode 100644 index 0000000000..669f9118aa --- /dev/null +++ b/inlang/source-code/bundle-component/src/stories/inlang-message.ts @@ -0,0 +1,387 @@ +import type { InstalledLintRule, LanguageTag, NestedMessage } from "@inlang/sdk-v2" +import type { Declaration, LintReport, Message, ProjectSettings2, Variant } from "@inlang/sdk-v2" +import { createVariant } from "@inlang/sdk-v2" +import { LitElement, css, html } from "lit" +import { customElement, property } from "lit/decorators.js" +import deleteSelector from "../helper/crud/selector/delete.js" +import upsertVariant from "../helper/crud/variant/upsert.js" + +import "./inlang-variant.js" +import "./inlang-selector-configurator.js" + +@customElement("inlang-message") +export default class InlangMessage extends LitElement { + static override styles = [ + css` + div { + box-sizing: border-box; + font-size: 13px; + } + :host { + position: relative; + display: flex; + min-height: 44px; + border: 1px solid var(--sl-input-border-color); + border-top: none; + } + .message:first-child { + border-top: 1px solid var(--sl-input-border-color); + } + .language-container { + font-weight: 500; + width: 80px; + min-height: 44px; + padding-top: 12px; + padding-left: 12px; + padding-right: 12px; + background-color: var(--sl-input-background-color-disabled); + border-right: 1px solid var(--sl-input-border-color); + color: var(--sl-input-color); + } + .message-body { + flex: 1; + display: flex; + flex-direction: column; + } + .message-header { + width: 100%; + min-height: 44px; + display: flex; + justify-content: space-between; + background-color: var(--sl-input-background-color-disabled); + color: var(--sl-input-color); + border-bottom: 1px solid var(--sl-input-border-color); + } + .no-bottom-border { + border-bottom: none; + } + .selector-container { + min-height: 44px; + display: flex; + } + .selector { + height: 44px; + width: 120px; + display: flex; + align-items: center; + padding: 12px; + border-right: 1px solid var(--sl-input-border-color); + font-weight: 500; + cursor: pointer; + } + sl-menu-item::part(label) { + font-size: 14px; + padding-left: 12px; + } + sl-menu-item::part(base) { + color: var(--sl-input-color); + } + sl-menu-item::part(base):hover { + background-color: var(--sl-input-background-color-hover); + } + sl-menu-item::part(checked-icon) { + display: none; + } + .selector:hover { + background-color: var(--sl-input-background-color-hover); + } + .add-selector-container { + height: 44px; + width: 44px; + display: flex; + align-items: center; + padding: 12px; + } + .add-selector::part(base) { + height: 28px; + width: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + } + .message-actions { + height: 44px; + display: flex; + align-items: center; + padding: 12px; + gap: 8px; + } + sl-button::part(base) { + background-color: var(--sl-input-background-color); + color: var(--sl-input-color); + border: 1px solid var(--sl-input-border-color); + } + sl-button::part(base):hover { + background-color: var(--sl-input-background-color-hover); + color: var(--sl-input-color-hover); + border: 1px solid var(--sl-input-border-color-hover); + } + .variants-container { + width: 100%; + height: 44px; + display: flex; + flex-direction: column; + height: auto; + } + .new-variant { + box-sizing: border-box; + min-height: 44px; + width: 100%; + display: flex; + gap: 4px; + align-items: center; + padding-left: 12px; + margin: 0; + background-color: var(--sl-input-background-color); + color: var(--sl-input-placeholder-color); + border-top: 1px solid var(--sl-input-border-color); + cursor: pointer; + transitions: all 0.5s; + } + .new-variant:hover { + background-color: var(--sl-input-background-color-hover); + color: var(--sl-input-color-hover); + } + .ref-tag::part(base) { + background-color: var(--sl-input-placeholder-color); + color: var(--sl-input-background-color); + height: 22px; + border: none; + } + `, + ] + + @property() + locale: LanguageTag | undefined + + @property() + message: NestedMessage | undefined + + @property() + messageValidationReports: Array | undefined + + @property() + installedLintRules: InstalledLintRule[] | undefined + + @property({ type: Object }) + settings: ProjectSettings2 | undefined + + @property({ type: Array }) + inputs: Declaration[] | undefined + + @property({ type: Array }) + freshlyAddedVariants: string[] = [] + + @property() + addInput: (name: string) => void = () => {} + + @property() + addMessage: (message: NestedMessage) => void = () => {} + + @property() + resetFreshlyAddedVariants: (newArray: string[]) => void = () => {} + + @property() + triggerMessageBundleRefresh: () => void = () => {} + + @property() + fixLint: (lintReport: LintReport, fix: LintReport["fixes"][0]["title"]) => void = () => {} + + dispatchOnInsertVariant(variant: Variant) { + const onInsertVariant = new CustomEvent("insert-variant", { + bubbles: true, + composed: true, + detail: { + argument: { + variant, + }, + }, + }) + this.dispatchEvent(onInsertVariant) + } + + private _refLocale = (): LanguageTag | undefined => { + return this.settings?.baseLocale + } + + override render() { + return html` +
+ ${this.locale} + ${this._refLocale() === this.locale + ? html`ref` + : ``} +
+
+ ${(this.message && this.message.selectors.length > 0) || + (this.message && this.message.variants.length > 1 && this.message.selectors.length === 0) + ? html`
+
+ ${this.message.selectors.map( + (selector: any, index: any) => html` +
+ ${ + // @ts-ignore + selector.arg.name + } +
+ + { + deleteSelector({ message: this.message!, index }) + this.triggerMessageBundleRefresh() + }} + > + + + + Delete selector + +
` + )} +
+ + + + + + + + +
+
+
+ ${this.message + ? this.freshlyAddedVariants.some((id) => + this.message!.variants.map((variant) => variant.id).includes(id) + ) + ? html` { + const newArray = this.freshlyAddedVariants.filter( + (id) => + !this.message!.variants.map((variant) => variant.id).includes(id) + ) + this.resetFreshlyAddedVariants(newArray) + this.requestUpdate() + this.triggerMessageBundleRefresh() + }} + > + + + + + + + Sort` + : `` + : ``} + ${this.messageValidationReports && this.messageValidationReports.length > 0 + ? html`` + : ``} +
+
` + : ``} +
+ + ${this.message?.selectors && this.message.selectors.length > 0 + ? html`

{ + const variant = createVariant({ + messageId: this.message!.id, + // combine the matches that are already present with the new category -> like a matrix + match: (() => { + const match: Record = {} + for (const selector of this.message!.selectors) { + match[selector.arg.name] = "null" + } + return match + })(), + }) + this.freshlyAddedVariants.push(variant.id) + upsertVariant({ + message: this.message!, + variant: variant, + }) + this.dispatchOnInsertVariant(variant) + this.triggerMessageBundleRefresh() + }} + class="new-variant" + > + + + + New variant +

` + : ``} +
+
+ ` + } +} + +declare global { + interface HTMLElementTagNameMap { + "inlang-message": InlangMessage + } +} diff --git a/inlang/source-code/bundle-component/src/stories/inlang-selector-configurator.ts b/inlang/source-code/bundle-component/src/stories/inlang-selector-configurator.ts new file mode 100644 index 0000000000..720740eb4c --- /dev/null +++ b/inlang/source-code/bundle-component/src/stories/inlang-selector-configurator.ts @@ -0,0 +1,639 @@ +import { LitElement, css, html } from "lit" +import { customElement, property, state } from "lit/decorators.js" + +import SlDropdown from "@shoelace-style/shoelace/dist/components/dropdown/dropdown.component.js" + +import { + type Declaration, + createMessage, + createVariant, + type LanguageTag, + type Message, + type Variant, + type NestedMessage, + type Expression, +} from "@inlang/sdk-v2" +import addSelector from "../helper/crud/selector/add.js" +import upsertVariant from "../helper/crud/variant/upsert.js" +import "./inlang-add-input.js" + +@customElement("inlang-selector-configurator") +export default class InlangSelectorConfigurator extends LitElement { + static override styles = [ + css` + .button-wrapper { + height: 44px; + display: flex; + align-items: center; + justify-content: center; + } + .dropdown-container { + font-size: 13px; + width: 240px; + background-color: var(--sl-panel-background-color); + border: 1px solid var(--sl-input-border-color); + padding: 12px; + border-radius: 6px; + display: flex; + flex-direction: column; + gap: 16px; + } + .dropdown-item { + display: flex; + flex-direction: column; + gap: 2px; + } + .dropdown-header { + display: flex; + justify-content: space-between; + align-items: center; + color: var(--sl-input-color); + font-size: 12px; + } + .dropdown-title { + font-size: 12px; + font-weight: 500; + margin: 6px 0; + } + .add-input::part(base) { + color: var(--sl-color-neutral-500); + } + .add-input::part(base):hover { + background-color: var(--sl-input-background-color-hover); + color: var(--sl-input-color-hover); + } + sl-select::part(form-control-label) { + font-size: 13px; + } + sl-select::part(display-input) { + font-size: 13px; + } + sl-option::part(label) { + font-size: 14px; + } + sl-menu-item::part(label) { + font-size: 14px; + padding-left: 12px; + } + sl-menu-item::part(base) { + color: var(--sl-input-color); + } + sl-menu-item::part(base):hover { + background-color: var(--sl-input-background-color-hover); + } + sl-menu-item::part(checked-icon) { + display: none; + } + .options-title { + font-size: 14px; + color: var(--sl-input-color); + background-color: var(--sl-input-background-color); + margin: 0; + padding-bottom: 4px; + } + .options-wrapper { + display: flex; + gap: 4px; + flex-wrap: wrap; + margin-top: 4px; + } + .option { + width: 100%; + } + .option::part(base) { + background-color: var(--sl-input-background-color-hover); + border-radius: var(--sl-input-border-radius-small); + } + .option { + width: 100%; + background-color: var(--sl-input-background-color-hover); + } + .delete-icon { + color: var(--sl-color-neutral-400); + cursor: pointer; + } + .delete-icon:hover { + color: var(--sl-input-color-hover); + } + .help-text { + display: flex; + gap: 8px; + color: var(--sl-input-help-text-color); + } + .help-text p { + flex: 1; + margin: 0; + font-size: 12px; + line-height: 1.5; + } + .empty-image { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + margin-top: 12px; + } + .actions { + width: 100%; + display: flex; + flex-direction: column; + gap: 4px; + } + sl-input::part(base) { + font-size: 13px; + } + `, + ] + + @property() + inputs: Declaration[] | undefined + + @property() + bundleId: string | undefined + + @property() + message?: NestedMessage | undefined + + @property() + locale: LanguageTag | undefined + + @property() + triggerMessageBundleRefresh: () => void = () => {} + + @property() + triggerSave: () => void = () => {} + + @property() + addMessage: (newMessage: NestedMessage) => void = () => {} + + @property() + addInput: (inputName: string) => void = () => {} + + @state() + private _input: string | undefined + + @state() + private _function: string | undefined + + @state() + private _matchers: string[] | undefined + + @state() + private _isNewInput: boolean = false + + @state() + private _newInputSting: string | undefined + + // events + dispatchOnInsertMessage(message: Message, variants: Variant[]) { + const onInsertMessage = new CustomEvent("insert-message", { + bubbles: true, + composed: true, + detail: { + argument: { + message, + variants, + }, + }, + }) + this.dispatchEvent(onInsertMessage) + } + + dispatchOnUpdateMessage(message: Message, variants: Variant[]) { + const onUpdateMessage = new CustomEvent("update-message", { + bubbles: true, + composed: true, + detail: { + argument: { + message, + variants, + }, + }, + }) + this.dispatchEvent(onUpdateMessage) + } + + dispatchOnInsertVariant(variant: Variant) { + const onInsertVariant = new CustomEvent("insert-variant", { + bubbles: true, + composed: true, + detail: { + argument: { + variant, + }, + }, + }) + this.dispatchEvent(onInsertVariant) + } + + dispatchOnUpdateVariant(variant: Variant) { + const onUpdateVariant = new CustomEvent("update-variant", { + bubbles: true, + composed: true, + detail: { + argument: { + variant, + }, + }, + }) + this.dispatchEvent(onUpdateVariant) + } + + private _getPluralCategories = (): string[] | undefined => { + return this.locale + ? [...new Intl.PluralRules(this.locale).resolvedOptions().pluralCategories, "*"] + : undefined + } + + private _handleAddSelector = (newMatchers: string[]) => { + // get dropdown by "dropdown" class + const dropdown = this.shadowRoot?.querySelector(".dropdown") as SlDropdown + if (dropdown) dropdown.hide() + + if (this._isNewInput && this._newInputSting && this._newInputSting.length > 0) { + this.addInput(this._newInputSting) + this._input = this._newInputSting + } + + if (this._input) { + if (!this.message && this.locale) { + // create selector in not present message + const newMessage = createMessage({ + bundleId: this.bundleId!, + locale: this.locale, + text: "", + }) + + // add selector + addSelector({ + message: newMessage, + selector: { + type: "expression", + arg: { + type: "variable", + name: this._input, + }, + annotation: { + type: "function", + name: this._function || "plural", + options: [], + }, + }, + }) + + this._addVariants({ + message: newMessage, + variantsMatcher: [], + newMatchers: newMatchers, + newSelectorName: this._input, + }) + this.addMessage(newMessage) + this.dispatchOnInsertMessage(newMessage, newMessage.variants) + for (const variant of newMessage.variants) { + this.dispatchOnInsertVariant(variant) + } + } else if (this.message) { + // get variant matchers arrays + const _variants = structuredClone(this.message ? this.message.variants : []) + const _variantsMatcher = _variants.map((variant) => variant.match) + + // add selector + addSelector({ + message: this.message, + selector: { + type: "expression", + arg: { + type: "variable", + name: this._input, + }, + annotation: { + type: "function", + name: this._function || "plural", + options: [], + }, + }, + }) + + const updatedVariants = structuredClone(this.message.variants) + this.dispatchOnUpdateMessage(this.message, updatedVariants) + + this._addVariants({ + message: this.message, + variantsMatcher: _variantsMatcher, + newMatchers: newMatchers, + newSelectorName: this._input, + }) + + // only inserted variants should be dispatched -> show filter + const insertedVariants = this.message.variants.filter( + (variant) => !updatedVariants.find((v) => v.id === variant.id) + ) + for (const variant of insertedVariants) { + this.dispatchOnInsertVariant(variant) + } + + for (const updatedVariant of updatedVariants) { + this.dispatchOnUpdateVariant(updatedVariant) + } + } + + this.triggerSave() + this.triggerMessageBundleRefresh() + } + } + + private _addVariants = (props: { + message: NestedMessage + variantsMatcher: Record[] + newMatchers: string[] + newSelectorName: string + }) => { + const newMatchers = props.newMatchers.filter((category) => category !== "*") + if (newMatchers) { + if (props.variantsMatcher && props.variantsMatcher.length > 0) { + for (const variantMatcher of props.variantsMatcher) { + for (const category of newMatchers) { + upsertVariant({ + message: props.message, + variant: createVariant({ + messageId: props.message.id, + // combine the matches that are already present with the new category -> like a matrix + match: { ...variantMatcher, ...{ [props.newSelectorName]: category } }, + }), + }) + } + } + } else { + for (const category of newMatchers) { + upsertVariant({ + message: props.message, + variant: createVariant({ + messageId: props.message.id, + // combine the matches that are already present with the new category -> like a matrix + match: { [props.newSelectorName]: category }, + }), + }) + } + } + } + } + + private _resetConfiguration = () => { + this._input = this.inputs && this.inputs[0] && this.inputs[0].name + this._function = "plural" + this._matchers = this._getPluralCategories() || ["*"] + } + + override async firstUpdated() { + await this.updateComplete + this._input = this.inputs && this.inputs[0] && this.inputs[0].name + this._function = "plural" + this._matchers = this._getPluralCategories() || ["*"] + } + + override render() { + return html` + { + const dropdown = this.shadowRoot?.querySelector("sl-dropdown") + if (dropdown && e.target === dropdown) { + this._input = + this.inputs && this.inputs.length > 0 && this.inputs[0] + ? this.inputs[0].name + : undefined + if (this.inputs && this.inputs.length === 0) { + this._isNewInput = true + } + } + }} + > +
+ +
+ +
+ ` + } +} + +declare global { + interface HTMLElementTagNameMap { + "inlang-selector-configurator": InlangSelectorConfigurator + } +} diff --git a/inlang/source-code/bundle-component/src/stories/inlang-variant.ts b/inlang/source-code/bundle-component/src/stories/inlang-variant.ts new file mode 100644 index 0000000000..b5d4783904 --- /dev/null +++ b/inlang/source-code/bundle-component/src/stories/inlang-variant.ts @@ -0,0 +1,411 @@ +import { + type Variant, + type NestedMessage, + type LanguageTag, + type LintReport, + type InstalledLintRule, + type Declaration, + type Expression, +} from "@inlang/sdk-v2" +import { LitElement, css, html } from "lit" +import { customElement, property } from "lit/decorators.js" + +//helpers +import deleteVariant from "../helper/crud/variant/delete.js" +import updateMatch from "../helper/crud/variant/updateMatch.js" + +// internal components +import "./inlang-lint-report-tip.js" +import "./inlang-selector-configurator.js" +import "./pattern-editor/inlang-pattern-editor.js" + +@customElement("inlang-variant") +export default class InlangVariant extends LitElement { + static override styles = [ + css` + div { + box-sizing: border-box; + font-size: 13px; + } + :host { + border-top: 1px solid var(--sl-input-border-color); + } + :host(:first-child) { + border-top: none; + } + .variant { + position: relative; + min-height: 44px; + width: 100%; + display: flex; + align-items: stretch; + } + .match { + min-height: 44px; + width: 120px; + background-color: var(--sl-input-background-color); + border-right: 1px solid var(--sl-input-border-color); + position: relative; + z-index: 0; + } + .match:focus-within { + z-index: 1; + } + .match::part(base) { + border: none; + border-radius: 0; + min-height: 44px; + } + .match::part(input) { + min-height: 44px; + } + .match::part(input):hover { + background-color: var(--sl-input-background-color-); + } + .pattern { + flex: 1; + background-color: none; + height: 44px; + position: relative; + z-index: 0; + } + .pattern:focus-within { + z-index: 1; + } + .pattern::part(base) { + border: none; + border-radius: 0; + min-height: 44px; + background-color: var(--sl-input-background-color); + } + .pattern::part(input) { + min-height: 44px; + } + .pattern::part(input):hover { + background-color: var(--sl-input-background-color-hover); + } + .pattern::part(input)::placeholder { + color: var(--sl-input-placeholder-color); + font-size: 13px; + } + .actions { + position: absolute; + top: 0; + right: 0; + height: 44px; + display: flex; + align-items: center; + gap: 4px; + padding-right: 12px; + z-index: 1; + } + .add-selector::part(base) { + border-radius: 4px; + cursor: pointer; + font-size: 13px; + } + sl-button::part(base) { + color: var(--sl-input-color); + background-color: var(--sl-input-background-color); + border: 1px solid var(--sl-input-border-color); + } + sl-button::part(base):hover { + color: var(--sl-input-color-hover); + background-color: var(--sl-input-background-color-hover); + border: 1px solid var(--sl-input-border-color-hover); + } + .dynamic-actions { + display: flex; + align-items: center; + gap: 4px; + z-index: 2; + } + .hide-dynamic-actions { + display: none; + } + .variant:hover .dynamic-actions { + display: flex; + } + .dropdown-open.dynamic-actions { + display: flex; + } + sl-tooltip::part(base) { + background-color: var(--sl-tooltip-background-color); + color: var(--sl-tooltip-color); + } + `, + ] + + @property() + bundleId: string | undefined + + //props + @property() + message: NestedMessage | undefined + + @property() + locale: LanguageTag | undefined + + @property() + variant: Variant | undefined + + @property() + inputs: Declaration[] | undefined + + @property() + variantValidationReports: Array | undefined + + @property() + messageValidationReports: Array | undefined + + @property() + installedLintRules: InstalledLintRule[] | undefined + + @property() + setHoveredVariantId: (variantId: string | undefined) => void = () => {} + + @property() + addMessage: (newMessage: NestedMessage) => void = () => {} + + @property() + addInput: (inputName: string) => void = () => {} + + @property() + triggerMessageBundleRefresh: () => void = () => {} + + @property() + triggerSave: () => void = () => {} + + @property() + fixLint: (lintReport: LintReport, fix: LintReport["fixes"][0]["title"]) => void = () => {} + + // @property() + // revert: (messageId?: string, variantId?: string) => void = () => {} + + dispatchOnDeleteVariant(variant: Variant) { + const onDeleteVariant = new CustomEvent("delete-variant", { + bubbles: true, + composed: true, + detail: { + argument: { + variant, + }, + }, + }) + this.dispatchEvent(onDeleteVariant) + } + + dispatchOnUpdateVariant(variant: Variant) { + const onUpdateVariant = new CustomEvent("update-variant", { + bubbles: true, + composed: true, + detail: { + argument: { + variant, + }, + }, + }) + this.dispatchEvent(onUpdateVariant) + } + + //functions + private _getLintReports = (): LintReport[] => { + // wether a lint report belongs to a variant or message and when they are shown + if ( + ((this.message?.selectors && this.message.selectors.length === 0) || + !this.message?.selectors) && + this.message?.variants.length === 1 + ) { + // when there are no selectors the reports of the message and variant are shown on variant level + return (this.messageValidationReports || []).concat(this.variantValidationReports || []) + } + + return this.variantValidationReports || [] + } + + private _delete = () => { + if (this.message && this.variant) { + deleteVariant({ + message: this.message, + variant: this.variant, + }) + this.dispatchOnDeleteVariant(this.variant) + this.triggerMessageBundleRefresh() + } + } + + private _updateMatch = (selectorName: string, value: string) => { + //TODO improve this function + if (this.variant && this.message) { + updateMatch({ + variant: this.variant, + selectorName, + value, + }) + const variantID = this.variant.id + + const changedVariant = this.message.variants.find((v) => v.id === variantID) + if (changedVariant) { + changedVariant.match[selectorName] = value + } + + this.dispatchOnUpdateVariant(this.variant) + this.triggerMessageBundleRefresh() + } + } + + //hooks + override async firstUpdated() { + await this.updateComplete + + // adds classes when dropdown is open, to keep it open when not hovering the variant + const selectorConfigurator = this.shadowRoot?.querySelector("inlang-selector-configurator") + const selectorDropdown = selectorConfigurator?.shadowRoot?.querySelector("sl-dropdown") + if (selectorDropdown) { + selectorDropdown.addEventListener("sl-show", (e) => { + if (e.target === selectorDropdown) { + //set parent class dropdown-open + selectorConfigurator?.parentElement?.classList.add("dropdown-open") + } + }) + selectorDropdown.addEventListener("sl-hide", (e) => { + if (e.target === selectorDropdown) { + //remove parent class dropdown-open + selectorConfigurator?.parentElement?.classList.remove("dropdown-open") + } + }) + } + } + + // hooks + override updated(changedProperties: any) { + // works like useEffect + // In order to not mutate object references, we need to clone the object + // When the messageBundle prop changes, we update the internal state + if (changedProperties.has("variantValidationReports", "messageValidationReports")) { + console.log("variantValidationReports or messageValidationReports changed") + // adds classes when dropdown is open, to keep it open when not hovering the variant + const lintReportsTip = this.shadowRoot?.querySelector("inlang-lint-report-tip") + const lintReportDropdown = lintReportsTip?.shadowRoot?.querySelector("sl-dropdown") + if (lintReportDropdown) { + const previousSibling = lintReportsTip?.previousSibling?.previousSibling?.previousSibling + lintReportDropdown.addEventListener("sl-show", (e) => { + if ( + e.target === lintReportDropdown && //set parent class dropdown-open + previousSibling instanceof HTMLElement + ) { + previousSibling.classList.add("dropdown-open") + } + }) + lintReportDropdown.addEventListener("sl-hide", (e) => { + if ( + e.target === lintReportDropdown && //remove parent class dropdown-open + previousSibling instanceof HTMLElement + ) { + previousSibling.classList.remove("dropdown-open") + } + }) + } + } + } + + override render() { + console.log(this.message) + return !(!this.variant && this.message && this.message?.selectors.length > 0) + ? html`
+ ${this.variant + ? Object.entries(this.variant.match).map(([selectorName, match]) => { + return html` + { + const element = this.shadowRoot?.getElementById( + `${this.message!.id}-${this.variant!.id}-${match}` + ) + if (element && e.target === element) { + this._updateMatch(selectorName, (e.target as HTMLInputElement).value) + } + }} + > + ` + }) + : undefined} + +
+
+ + ${(this.message?.selectors.length === 0 && this.message?.variants.length <= 1) || + !this.message?.selectors + ? html` + + + + + Selector + + + ` + : ``} + ${(this.message && this.variant && this.message.selectors.length > 0) || + (this.message && this.variant && this.message.variants.length > 1) + ? html` this._delete()} + > + + + + ` + : ``} +
+ + ${this._getLintReports() && this._getLintReports()!.length > 0 + ? html`` + : ``} +
+
` + : undefined + } +} + +declare global { + interface HTMLElementTagNameMap { + "inlang-variant": InlangVariant + } +} diff --git a/inlang/source-code/bundle-component/src/stories/pattern-editor/inlang-pattern-editor.stories.ts b/inlang/source-code/bundle-component/src/stories/pattern-editor/inlang-pattern-editor.stories.ts new file mode 100644 index 0000000000..92569b1d19 --- /dev/null +++ b/inlang/source-code/bundle-component/src/stories/pattern-editor/inlang-pattern-editor.stories.ts @@ -0,0 +1,40 @@ +import "./inlang-pattern-editor.ts" +import "./../testing/reactivePatternEditorWrapper.ts" +import type { Meta, StoryObj } from "@storybook/web-components" +import { html } from "lit" +import { pluralBundle } from "@inlang/sdk-v2" +import stringToPattern from "../../helper/crud/pattern/stringToPattern.ts" + +const meta: Meta = { + component: "inlang-pattern-editor", + title: "Public/inlang-pattern-editor", +} + +export default meta + +export const Simple: StoryObj = { + render: () => + html` + console.log("update")} + > + + `, +} + +export const Empty: StoryObj = { + render: () => + html` + console.log("update")} + > + + `, +} + +export const Reactive: StoryObj = { + render: () => + html` `, +} diff --git a/inlang/source-code/bundle-component/src/stories/pattern-editor/inlang-pattern-editor.ts b/inlang/source-code/bundle-component/src/stories/pattern-editor/inlang-pattern-editor.ts new file mode 100644 index 0000000000..c4c7d06e4a --- /dev/null +++ b/inlang/source-code/bundle-component/src/stories/pattern-editor/inlang-pattern-editor.ts @@ -0,0 +1,210 @@ +import type { Pattern } from "@inlang/sdk-v2" +import { LitElement, css, html, type PropertyValues } from "lit" +import { customElement, property, state } from "lit/decorators.js" +import { ref, createRef, type Ref } from "lit/directives/ref.js" +import { createEditor } from "lexical" +import { registerPlainText } from "@lexical/plain-text" +import { $getRoot, $createParagraphNode, $createTextNode } from "lexical" +import patternToString from "../../helper/crud/pattern/patternToString.js" +import stringToPattern from "../../helper/crud/pattern/stringToPattern.js" + +//editor config +const config = { + namespace: "MyEditor", + onError: console.error, +} + +@customElement("inlang-pattern-editor") +export default class InlangPatternEditor extends LitElement { + static override styles = [ + css` + .editor-wrapper { + background-color: #f0f0f0; + } + `, + ] + + // refs + contentEditableElementRef: Ref = createRef() + + // props + @property({ type: Array }) + pattern: Pattern | undefined + + //state + @state() + _patternState: Pattern | undefined + + // dispatch `change-pattern` event with the new pattern + dispatchOnChangePattern(pattern: Pattern) { + const onChangePattern = new CustomEvent("change-pattern", { + detail: { + argument: pattern, + }, + }) + this.dispatchEvent(onChangePattern) + } + + //disable shadow root -> because of contenteditable selection API + override createRenderRoot() { + return this + } + + // create editor + editor = createEditor(config) + + override updated(changedProperties: PropertyValues) { + if ( + changedProperties.has("pattern") && + JSON.stringify(this.pattern as any) !== JSON.stringify(this._patternState) + ) { + this._setEditorState() + } + } + + // // update editor when pattern changes + // override updated(changedProperties: Map) { + // if ( + // changedProperties.has("pattern") + // // TODO how do wset the pettern? + // ) { + // // debugger + // this._setEditorState() + // } + // } + + // set editor state + private _setEditorState = () => { + this._removeTextContentListener?.() + + this._patternState = this.pattern + // only handling strings so far -> TODO: handle real patterns + this.editor.update( + () => { + const root = $getRoot() + if (root.getChildren().length === 0) { + const paragraphNode = $createParagraphNode() + const textNode = $createTextNode( + this.pattern ? patternToString({ pattern: this.pattern }) : "" + ) + paragraphNode.append(textNode) + root.append(paragraphNode) + } else { + const paragraphNode = root.getChildren()[0]! + paragraphNode.remove() + const newpParagraphNode = $createParagraphNode() + const textNode = $createTextNode( + this.pattern ? patternToString({ pattern: this.pattern }) : "" + ) + newpParagraphNode.append(textNode) + root.append(newpParagraphNode) + } + }, + { + discrete: true, + } + ) + + // if (reAddEventListner) { + this._removeTextContentListener = this.editor.registerTextContentListener( + (textContent: any) => { + // The latest text content of the editor! + this._patternState = stringToPattern({ text: textContent }) + // this.requestUpdate("pattern") + //check if something changed + this.dispatchOnChangePattern(this._patternState) + } + ) + // } + } + + override async firstUpdated() { + const contentEditableElement = this.contentEditableElementRef.value + if (contentEditableElement) { + // set root element of editor and register plain text + this.editor.setRootElement(contentEditableElement) + registerPlainText(this.editor) + + // listen to text content changes and dispatch `change-pattern` event + this._removeTextContentListener = this.editor.registerTextContentListener( + (textContent: any) => { + // The latest text content of the editor! + //check if something changed + + this._patternState = stringToPattern({ text: textContent }) + // this.requestUpdate("pattern") + //check if something changed + this.dispatchOnChangePattern(this._patternState) + } + ) + } + } + + private _removeTextContentListener: undefined | (() => void) + + override render() { + return html` + +
+
+ ${this.pattern === undefined || this.pattern?.length === 0 + ? html`

Enter pattern ...

` + : ""} +
+ ` + } +} + +declare global { + interface HTMLElementTagNameMap { + "inlang-pattern-editor": InlangPatternEditor + } +} diff --git a/inlang/source-code/bundle-component/src/stories/testing/reactivePatternEditorWrapper.ts b/inlang/source-code/bundle-component/src/stories/testing/reactivePatternEditorWrapper.ts new file mode 100644 index 0000000000..a58ae76caf --- /dev/null +++ b/inlang/source-code/bundle-component/src/stories/testing/reactivePatternEditorWrapper.ts @@ -0,0 +1,45 @@ +import { LitElement, html } from "lit" +import { customElement, state } from "lit/decorators.js" +import "./../pattern-editor/inlang-pattern-editor.js" +import type { Pattern } from "@inlang/sdk-v2" + +@customElement("inlang-reactive-pattern-editor-wrapper") +export default class InlangReactivePatternEditorWrapper extends LitElement { + @state() + _pattern: Pattern = [ + { + type: "text", + value: "0", + }, + ] + + //disable shadow root -> because of contenteditable selection API + override createRenderRoot() { + return this + } + + override async firstUpdated() { + setInterval(() => { + this._pattern = [ + { + type: "text", + value: Number(Math.random() * 100).toFixed(0), + }, + ] + }, 4000) + } + + override render() { + console.log("render-wrapper", this._pattern) + return html` console.log("update", pattern.detail.argument)} + >` + } +} + +declare global { + interface HTMLElementTagNameMap { + "inlang-reactive-pattern-editor-wrapper": InlangReactivePatternEditorWrapper + } +} diff --git a/inlang/source-code/bundle-component/src/stories/testing/reactiveWrapper.ts b/inlang/source-code/bundle-component/src/stories/testing/reactiveWrapper.ts new file mode 100644 index 0000000000..982b1694cd --- /dev/null +++ b/inlang/source-code/bundle-component/src/stories/testing/reactiveWrapper.ts @@ -0,0 +1,86 @@ +import { LitElement, html } from "lit" +import { customElement, property, state } from "lit/decorators.js" + +import "./../inlang-bundle.js" +import "./../actions/inlang-bundle-action.js" +import type { InstalledLintRule, LintReport, NestedBundle, ProjectSettings2 } from "@inlang/sdk-v2" +// import type { InstalledMessageLintRule } from "@inlang/sdk-v2" +import { pluralBundle } from "@inlang/sdk-v2" + +import { + mockInstalledLintRules, + // mockMessageLintReports, + // mockVariantLintReports, +} from "../../mock/lint.js" +import { mockSettings } from "../../mock/settings.js" + +@customElement("inlang-reactive-wrapper") +export default class InlangReactiveWrapper extends LitElement { + @property({ type: Object }) + bundle: NestedBundle | undefined + + @property({ type: Object }) + settings: ProjectSettings2 | undefined + + @property({ type: Array }) + installedLintRules: InstalledLintRule[] | undefined + + @state() + _lintReports: { hash: string; reports: LintReport[] } | undefined + + //disable shadow root -> because of contenteditable selection API + override createRenderRoot() { + return this + } + + override async firstUpdated() { + // TODO SDK-v2 LINT reports - mock + // this._lintReports = { + // hash: "hash", + // reports: [], + // } + // setInterval(() => { + // if (this._lintReports) { + // this._lintReports = { + // ...this._lintReports, + // reports: [...mockMessageLintReports, ...mockVariantLintReports], + // } + // } + // }, 1900) + // setInterval(() => { + // if (this._lintReports) { + // this._lintReports = { + // ...this._lintReports, + // reports: [], + // } + // } + // }, 3100) + } + + override render() { + return html` { + this.bundle = data.detail.argument + }} + @fix-lint=${(data: any) => console.info("fixLint", data.detail.argument)} + > + console.log("Share")} + > + console.log("Edit alias")} + > + ` + } +} + +declare global { + interface HTMLElementTagNameMap { + "inlang-reactive-wrapper": InlangReactiveWrapper + } +} diff --git a/inlang/source-code/message-bundle-component/src/styling/base.ts b/inlang/source-code/bundle-component/src/styling/base.ts similarity index 99% rename from inlang/source-code/message-bundle-component/src/styling/base.ts rename to inlang/source-code/bundle-component/src/styling/base.ts index 1804bb1b7c..bf84eecca7 100644 --- a/inlang/source-code/message-bundle-component/src/styling/base.ts +++ b/inlang/source-code/bundle-component/src/styling/base.ts @@ -1,8 +1,8 @@ import { css } from "lit" /* -* This gets into the published component -*/ + * This gets into the published component + */ export const baseStyling = css` :host { @@ -429,7 +429,7 @@ export const baseStyling = css` --sl-input-height-large: 3.125rem; /* 50px */ --sl-input-background-color: var(--sl-color-neutral-0); - --sl-input-background-color-hover: var(--sl-input-background-color); + --sl-input-background-color-hover: var(--sl-color-neutral-50); --sl-input-background-color-focus: var(--sl-input-background-color); --sl-input-background-color-disabled: var(--sl-color-neutral-100); --sl-input-border-color: var(--sl-color-neutral-300); diff --git a/inlang/source-code/message-bundle-component/src/styling/preview.css b/inlang/source-code/bundle-component/src/styling/preview.css similarity index 100% rename from inlang/source-code/message-bundle-component/src/styling/preview.css rename to inlang/source-code/bundle-component/src/styling/preview.css diff --git a/inlang/source-code/message-bundle-component/tsconfig.json b/inlang/source-code/bundle-component/tsconfig.json similarity index 100% rename from inlang/source-code/message-bundle-component/tsconfig.json rename to inlang/source-code/bundle-component/tsconfig.json diff --git a/inlang/source-code/message-bundle-component/src/helper/crud/input/get.ts b/inlang/source-code/message-bundle-component/src/helper/crud/input/get.ts deleted file mode 100644 index 45dd6e9926..0000000000 --- a/inlang/source-code/message-bundle-component/src/helper/crud/input/get.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { MessageBundle } from "@inlang/sdk/v2" - -export const getInputs = (props: { messageBundle: MessageBundle }) => { - const inputs: string[] = [] - for (const message of props.messageBundle.messages) { - for (const declaration of message.declarations) { - if (declaration.type === "input" && !inputs.includes(declaration.name)) { - inputs.push(declaration.name) - } - } - } - return inputs -} diff --git a/inlang/source-code/message-bundle-component/src/helper/crud/selector/add.ts b/inlang/source-code/message-bundle-component/src/helper/crud/selector/add.ts deleted file mode 100644 index 26cf60d145..0000000000 --- a/inlang/source-code/message-bundle-component/src/helper/crud/selector/add.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { Expression, Message } from "@inlang/sdk/v2" - -export const addSelector = (props: { message: Message; selector: Expression }) => { - props.message.selectors.push(props.selector) - for (const variant of props.message.variants) { - variant.match.push("*") - } -} diff --git a/inlang/source-code/message-bundle-component/src/helper/crud/variant/delete.ts b/inlang/source-code/message-bundle-component/src/helper/crud/variant/delete.ts deleted file mode 100644 index 9da2d0c397..0000000000 --- a/inlang/source-code/message-bundle-component/src/helper/crud/variant/delete.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Message, Variant } from "@inlang/sdk/v2" - -const deleteVariant = (props: { message: Message; variant: Variant }) => { - const uniqueMatch = props.variant.match - // index from array where the variant is located - const index = props.message.variants.findIndex( - (variant) => JSON.stringify(variant.match) === JSON.stringify(uniqueMatch) - ) - - if (index > -1) { - // Delete existing variant - props.message.variants.splice(index, 1) - } -} - -export default deleteVariant diff --git a/inlang/source-code/message-bundle-component/src/helper/crud/variant/isCatchAll.ts b/inlang/source-code/message-bundle-component/src/helper/crud/variant/isCatchAll.ts deleted file mode 100644 index eeb679d825..0000000000 --- a/inlang/source-code/message-bundle-component/src/helper/crud/variant/isCatchAll.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Variant } from "@inlang/sdk/v2" - -const variantIsCatchAll = (props: { variant: Variant }): boolean => { - if (props.variant.match.filter((match) => match !== "*").length === 0) { - return true - } else { - return false - } -} - -export default variantIsCatchAll diff --git a/inlang/source-code/message-bundle-component/src/helper/crud/variant/sort.ts b/inlang/source-code/message-bundle-component/src/helper/crud/variant/sort.ts deleted file mode 100644 index cc4a162b77..0000000000 --- a/inlang/source-code/message-bundle-component/src/helper/crud/variant/sort.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Variant } from "@inlang/sdk/v2" - -export const getNewVariantPosition = (props: { - variants: Variant[] - newVariant: Variant -}): number => { - const compareMatches = (variant: Variant, newVariant: Variant, index: number = 0): number => { - const variantMatch = variant.match[index] - const newVariantMatch = newVariant.match[index] - if (variantMatch && newVariantMatch && variantMatch.localeCompare(newVariantMatch) === -1) { - return -1 - } else if ( - variantMatch && - newVariantMatch && - variantMatch.localeCompare(newVariantMatch) === 0 - ) { - if (index < variant.match.length - 1) { - return compareMatches(variant, newVariant, index + 1) - } else { - return 0 - } - } else { - return 1 - } - } - - for (let i = 0; i < props.variants.length; i++) { - const _variant = props.variants[i] - if (_variant) { - const comparisonResult = compareMatches(_variant, props.newVariant) - if (comparisonResult === -1) { - return i - } - } - } - - return props.variants.length -} diff --git a/inlang/source-code/message-bundle-component/src/helper/crud/variant/updateMatch.ts b/inlang/source-code/message-bundle-component/src/helper/crud/variant/updateMatch.ts deleted file mode 100644 index 996b0a3060..0000000000 --- a/inlang/source-code/message-bundle-component/src/helper/crud/variant/updateMatch.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { Variant } from "@inlang/sdk/v2" - -const updateMatch = (props: { variant: Variant; matchIndex: number; value: string }) => { - // update the match at index matchIndex with value (mutates variant) - props.variant.match[props.matchIndex] = props.value -} - -export default updateMatch diff --git a/inlang/source-code/message-bundle-component/src/helper/crud/variant/upsert.test.ts b/inlang/source-code/message-bundle-component/src/helper/crud/variant/upsert.test.ts deleted file mode 100644 index 08ccec9851..0000000000 --- a/inlang/source-code/message-bundle-component/src/helper/crud/variant/upsert.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { createMessage, createMessageBundle } from "@inlang/sdk/v2" -import { describe, expect, it } from "vitest" -import upsertVariant from "./upsert.js" - -describe("upsertVariant", () => { - it("Should update existing variant", () => { - const bundle = createMessageBundle({ - id: "bundle-id", - messages: [createMessage({ locale: "en", text: "Hello World", match: ["*"] })], - }) - - expect(bundle.messages).toHaveLength(1) - expect(bundle.messages[0]?.variants[0]?.pattern).toStrictEqual([ - { type: "text", value: "Hello World" }, - ]) - - upsertVariant({ - message: bundle.messages[0]!, - variant: { match: ["*"], pattern: [{ type: "text", value: "Hello Universe" }] }, - }) - - expect(bundle.messages[0]?.variants).toHaveLength(1) - expect(bundle.messages[0]?.variants[0]?.pattern).toStrictEqual([ - { type: "text", value: "Hello Universe" }, - ]) - }) - - it("Should create a new variant", () => { - const bundle = createMessageBundle({ - id: "bundle-id", - messages: [createMessage({ locale: "en", text: "Hello World", match: ["*"] })], - }) - - expect(bundle.messages).toHaveLength(1) - expect(bundle.messages[0]?.variants[0]?.pattern).toStrictEqual([ - { type: "text", value: "Hello World" }, - ]) - - upsertVariant({ - message: bundle.messages[0]!, - variant: { match: ["one"], pattern: [{ type: "text", value: "Hello Universe" }] }, - }) - - expect(bundle.messages[0]?.variants).toHaveLength(2) - // it's 0 because it's sorted alphabetically - expect(bundle.messages[0]?.variants[0]?.pattern).toStrictEqual([ - { type: "text", value: "Hello Universe" }, - ]) - }) -}) diff --git a/inlang/source-code/message-bundle-component/src/helper/crud/variant/upsert.ts b/inlang/source-code/message-bundle-component/src/helper/crud/variant/upsert.ts deleted file mode 100644 index a3a4feaba3..0000000000 --- a/inlang/source-code/message-bundle-component/src/helper/crud/variant/upsert.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Message, Variant } from "@inlang/sdk/v2" -import { getNewVariantPosition } from "./sort.js" - -/** - * Upsert a variant into a message. If a variant with the same match already exists, it will be updated, otherwise a new variant will be added. - * The function mutates message. - * @param props.message The message to upsert the variant into. - * @param props.variant The variant to upsert. - */ - -const upsertVariant = (props: { message: Message; variant: Variant }) => { - const uniqueMatch = props.variant.match - const existingVariant = props.message.variants.find( - (variant) => JSON.stringify(variant.match) === JSON.stringify(uniqueMatch) - ) - - if (existingVariant) { - // Update existing variant - existingVariant.pattern = props.variant.pattern - } else { - // Add new variant - const newpos = getNewVariantPosition({ - variants: props.message.variants, - newVariant: props.variant, - }) - insertItemAtIndex(props.message.variants, newpos, props.variant) - } -} - -export default upsertVariant - -function insertItemAtIndex(variants: Variant[], index: number, newVariant: Variant) { - if (variants.length === 0) { - variants.push(newVariant) - } else { - variants.splice(index, 0, newVariant) - } -} diff --git a/inlang/source-code/message-bundle-component/src/index.ts b/inlang/source-code/message-bundle-component/src/index.ts deleted file mode 100644 index ef2d433baa..0000000000 --- a/inlang/source-code/message-bundle-component/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as InlangMessageBundle } from "./stories/inlang-message-bundle.js" diff --git a/inlang/source-code/message-bundle-component/src/inlang-message-bundle.mdx b/inlang/source-code/message-bundle-component/src/inlang-message-bundle.mdx deleted file mode 100644 index 3caa5fb6b6..0000000000 --- a/inlang/source-code/message-bundle-component/src/inlang-message-bundle.mdx +++ /dev/null @@ -1,6 +0,0 @@ -import { Meta, Primary, Controls, Story, Canvas, ArgTypes, Source } from '@storybook/blocks'; -import * as InlangMessageBundle from './stories/inlang-message-bundle.stories'; - - - -# Inlang Message Bundle \ No newline at end of file diff --git a/inlang/source-code/message-bundle-component/src/stories/inlang-lint-report-tip.ts b/inlang/source-code/message-bundle-component/src/stories/inlang-lint-report-tip.ts deleted file mode 100644 index a8bd70efd7..0000000000 --- a/inlang/source-code/message-bundle-component/src/stories/inlang-lint-report-tip.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { LitElement, css, html } from "lit" -import { customElement, property } from "lit/decorators.js" -import type { MessageLintReport } from "@inlang/message-lint-rule" - -import SlToolTip from "@shoelace-style/shoelace/dist/components/tooltip/tooltip.component.js" - -// in case an app defines it's own set of shoelace components, prevent double registering -if (!customElements.get("sl-tooltip")) customElements.define("sl-tooltip", SlToolTip) - -@customElement("inlang-lint-report-tip") -export default class InlangLintReportTip extends LitElement { - static override styles = [ - css` - .lint-report-tip { - height: 29px; - width: 29px; - color: var(--sl-color-danger-700); - display: flex; - align-items: center; - justify-content: center; - border-radius: 4px; - } - .lint-report-tip:hover { - background-color: var(--sl-color-danger-200); - } - `, - ] - - @property() - lintReports: MessageLintReport[] | undefined - - override render() { - return html` -
- - - -
- ` - } -} - -declare global { - interface HTMLElementTagNameMap { - "inlang-lint-report-tip": InlangLintReportTip - } -} diff --git a/inlang/source-code/message-bundle-component/src/stories/inlang-message-bundle.stories.ts b/inlang/source-code/message-bundle-component/src/stories/inlang-message-bundle.stories.ts deleted file mode 100644 index c358a5b916..0000000000 --- a/inlang/source-code/message-bundle-component/src/stories/inlang-message-bundle.stories.ts +++ /dev/null @@ -1,109 +0,0 @@ -import "./inlang-message-bundle.ts" -import type { Meta, StoryObj } from "@storybook/web-components" -import { html } from "lit" -import { multipleMatcherBundle, pluralBundle } from "@inlang/sdk/v2-mocks" -import { simplifyBundle } from "../helper/simplifyBundle.js" -import { createMessage, type MessageBundle } from "@inlang/sdk/v2" -import type { MessageLintReport } from "@inlang/message-lint-rule" -import { ProjectSettings } from "@inlang/sdk" - -const meta: Meta = { - component: "inlang-message-bundle", - title: "Public/inlang-message-bundle", -} - -const mockLintReports: MessageLintReport[] = [ - { - ruleId: "messageLintRule.inlang.missingTranslation", - messageId: "message-id", - languageTag: "de", - body: "test message", - level: "error", - }, -] - -const mockSettings: ProjectSettings = { - $schema: "https://inlang.com/schema/project-settings", - sourceLanguageTag: "en", - languageTags: ["en", "de", "nl", "ru"], - messageLintRuleLevels: { - "messageLintRule.inlang.identicalPattern": "error", - }, - modules: [ - "https://cdn.jsdelivr.net/npm/@inlang/plugin-i18next@4/dist/index.js", - "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-empty-pattern@latest/dist/index.js", - "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-identical-pattern@latest/dist/index.js", - "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-missing-translation@latest/dist/index.js", - "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-without-source@latest/dist/index.js", - ], - "plugin.inlang.i18next": { - pathPattern: { - brain: "./frontend/public/locales/{languageTag}/brain.json", - chat: "./frontend/public/locales/{languageTag}/chat.json", - config: "./frontend/public/locales/{languageTag}/config.json", - contact: "./frontend/public/locales/{languageTag}/contact.json", - deleteOrUnsubscribeFormBrain: - "./frontend/public/locales/{languageTag}/deleteOrUnsubscribeFormBrain.json", - explore: "./frontend/public/locales/{languageTag}/explore.json", - external_api_definition: - "./frontend/public/locales/{languageTag}/external_api_definition.json", - home: "./frontend/public/locales/{languageTag}/home.json", - invitation: "./frontend/public/locales/{languageTag}/invitation.json", - knowledge: "./frontend/public/locales/{languageTag}/knowledge.json", - login: "./frontend/public/locales/{languageTag}/login.json", - logout: "./frontend/public/locales/{languageTag}/logout.json", - monetization: "./frontend/public/locales/{languageTag}/monetization.json", - translation: "./frontend/public/locales/{languageTag}/translation.json", - upload: "./frontend/public/locales/{languageTag}/upload.json", - user: "./frontend/public/locales/{languageTag}/user.json", - }, - }, -} - -const simplifiedBundle = simplifyBundle(multipleMatcherBundle) - -export default meta - -export const Props: StoryObj = { - render: () => - html` - console.info("changeMessageBundle", messageBundle)} - > `, -} - -const bundleWithoutSelectors: MessageBundle = { - id: "message-bundle-id", - messages: [ - createMessage({ locale: "en", text: "Hello World!" }), - createMessage({ locale: "de", text: "Hallo Welt!" }), - ], - alias: { - default: "alias", - }, -} - -export const Simple: StoryObj = { - render: () => - html` - console.info("changeMessageBundle", messageBundle)} - > `, -} - -export const WithSelectors: StoryObj = { - render: () => - html` - console.info("changeMessageBundle", messageBundle)} - > `, -} diff --git a/inlang/source-code/message-bundle-component/src/stories/inlang-message-bundle.styles.ts b/inlang/source-code/message-bundle-component/src/stories/inlang-message-bundle.styles.ts deleted file mode 100644 index 8b353feba1..0000000000 --- a/inlang/source-code/message-bundle-component/src/stories/inlang-message-bundle.styles.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { css } from "lit" - -/* - * This gets into the published component - */ - -export const messageBundleStyling = css` - div { - box-sizing: border-box; - font-size: 13px; - } - .header { - font-weight: 600; - background-color: var(--sl-color-neutral-300); - padding: 12px; - display: flex; - align-items: center; - gap: 10px; - min-height: 44px; - } - .messages-container { - width: 100%; - margin-bottom: 16px; - } - .message { - position: relative; - display: flex; - min-height: 44px; - width: 100%; - border: 1px solid var(--sl-color-neutral-300); - border-top: none; - } - .message:first-child { - border-top: 1px solid var(--sl-color-neutral-300); - } - .language-container { - width: 80px; - min-height: 44px; - padding: 12px; - background-color: var(--sl-color-neutral-100); - border-right: 1px solid var(--sl-color-neutral-300); - } - .message-body { - flex: 1; - display: flex; - flex-direction: column; - } - .message-header { - width: 100%; - min-height: 44px; - display: flex; - justify-content: space-between; - background-color: var(--sl-color-neutral-100); - border-bottom: 1px solid var(--sl-color-neutral-300); - } - .no-bottom-border { - border-bottom: none; - } - .selector-container { - min-height: 44px; - display: flex; - } - .selector { - height: 44px; - width: 120px; - display: flex; - align-items: center; - padding: 12px; - border-right: 1px solid var(--sl-color-neutral-300); - font-weight: 600; - } - .add-selector-container { - height: 44px; - width: 44px; - display: flex; - align-items: center; - padding: 12px; - } - .add-selector { - height: 28px; - width: 28px; - display: flex; - align-items: center; - justify-content: center; - color: var(--sl-color-neutral-600); - border-radius: 4px; - border: 1px solid var(--sl-color-neutral-300); - background-color: var(--sl-color-neutral-0); - cursor: pointer; - font-size: 13px; - } - .add-selector:hover { - color: var(--sl-color-neutral-900); - background-color: var(--sl-color-neutral-200); - border: 1px solid var(--sl-color-neutral-400); - } - .message-actions { - height: 44px; - display: flex; - align-items: center; - padding: 12px; - } - .variants-container { - width: 100%; - height: 44px; - display: flex; - flex-direction: column; - height: auto; - } - .new-variant { - box-sizing: border-box; - min-height: 44px; - width: 100%; - display: flex; - gap: 4px; - align-items: center; - padding-left: 12px; - margin: 0; - background-color: var(--sl-color-neutral-0); - color: var(--sl-color-neutral-400); - border-top: 1px solid var(--sl-color-neutral-300); - cursor: pointer; - transitions: all 0.5s; - } - .new-variant:hover { - background-color: var(--sl-color-neutral-50); - color: var(--sl-color-neutral-700); - } -` diff --git a/inlang/source-code/message-bundle-component/src/stories/inlang-message-bundle.ts b/inlang/source-code/message-bundle-component/src/stories/inlang-message-bundle.ts deleted file mode 100644 index 8505865948..0000000000 --- a/inlang/source-code/message-bundle-component/src/stories/inlang-message-bundle.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { html, LitElement } from "lit" -import { customElement, property } from "lit/decorators.js" -import { baseStyling } from "../styling/base.js" -import overridePrimitiveColors from "../helper/overridePrimitiveColors.js" -import type { MessageBundle, Message, LanguageTag } from "@inlang/sdk/v2" // Import the types -import { messageBundleStyling } from "./inlang-message-bundle.styles.js" -import upsertVariant from "../helper/crud/variant/upsert.js" - -import "./inlang-variant.js" -import "./inlang-lint-report-tip.js" -import "./inlang-selector-configurator.js" - -import SlInput from "@shoelace-style/shoelace/dist/components/input/input.component.js" -import SlButton from "@shoelace-style/shoelace/dist/components/button/button.component.js" -import SlTooltip from "@shoelace-style/shoelace/dist/components/tooltip/tooltip.component.js" -import type { MessageLintReport, ProjectSettings } from "@inlang/sdk" -import { getInputs } from "../helper/crud/input/get.js" - -// in case an app defines it's own set of shoelace components, prevent double registering -if (!customElements.get("sl-input")) customElements.define("sl-input", SlInput) -if (!customElements.get("sl-button")) customElements.define("sl-button", SlButton) -if (!customElements.get("sl-tooltip")) customElements.define("sl-tooltip", SlTooltip) - -@customElement("inlang-message-bundle") -export default class InlangMessageBundle extends LitElement { - static override styles = [baseStyling, messageBundleStyling] - - @property({ type: Object }) - messageBundle: MessageBundle | undefined - - @property({ type: Object }) - settings: ProjectSettings | undefined - - @property({ type: Object }) - lintReports: MessageLintReport[] | undefined - - dispatchOnSetSettings(messageBundle: MessageBundle) { - const onChangeMessageBundle = new CustomEvent("change-message-bundle", { - detail: { - argument: messageBundle, - }, - }) - this.dispatchEvent(onChangeMessageBundle) - } - - _triggerSave = () => { - if (this.messageBundle) { - this.dispatchOnSetSettings(this.messageBundle) - } - } - - _addMessage = (message: Message) => { - if (this.messageBundle) { - this.messageBundle.messages.push(message) - this.requestUpdate() - } - } - - _triggerRefresh = () => { - this.requestUpdate() - } - - override async firstUpdated() { - await this.updateComplete - // override primitive colors to match the design system - overridePrimitiveColors() - } - - private _refLanguageTag = (): LanguageTag | undefined => { - return this.settings?.sourceLanguageTag - } - - private _languageTags = (): LanguageTag[] | undefined => { - return this.settings?.languageTags - } - - private _fakeInputs = (): string[] | undefined => { - const _refLanguageTag = this._refLanguageTag() - return _refLanguageTag && this.messageBundle - ? getInputs({ messageBundle: this.messageBundle }) - : undefined - } - - override render() { - return html` -
- # ${this.messageBundle?.id} - @${this.messageBundle?.alias.default} -
-
- ${this._languageTags() && - this._languageTags()?.map((languageTag) => { - const message = this.messageBundle?.messages.find( - (message) => message.locale === languageTag - ) - - return this._renderMessage( - languageTag, - message, - this.lintReports?.filter((report) => report.languageTag === languageTag) - ) - })} -
- ` - } - - private _renderMessage( - languageTag: LanguageTag, - message?: Message, - messageLintReports?: MessageLintReport[] - ) { - return html` -
-
- ${languageTag} -
-
- ${message && message.selectors.length > 0 - ? html`
-
- ${message.selectors.map( - // @ts-ignore - (selector) => html`
${selector.arg.name}
` - )} -
- -
- - - -
-
-
-
-
-
- ${messageLintReports && messageLintReports.length > 0 - ? html`` - : ``} -
-
` - : ``} -
- ${message - ? message.variants.map( - (variant) => - html`` - ) - : html``} - ${message?.selectors && message.selectors.length > 0 - ? html`

{ - upsertVariant({ - message: message, - variant: { - // combine the matches that are already present with the new category -> like a matrix - match: message.selectors.map(() => "null"), - pattern: [ - { - type: "text", - value: "", - }, - ], - }, - }) - this._triggerRefresh() - }} - class="new-variant" - > - - - - New variant -

` - : ``} -
-
-
- ` - } -} - -// add types -declare global { - interface HTMLElementTagNameMap { - "inlang-message-bundle": InlangMessageBundle - } -} diff --git a/inlang/source-code/message-bundle-component/src/stories/inlang-selector-configurator.ts b/inlang/source-code/message-bundle-component/src/stories/inlang-selector-configurator.ts deleted file mode 100644 index 46bc0f9028..0000000000 --- a/inlang/source-code/message-bundle-component/src/stories/inlang-selector-configurator.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { LitElement, css, html } from "lit" -import { customElement, property, state } from "lit/decorators.js" - -import SlDropdown from "@shoelace-style/shoelace/dist/components/dropdown/dropdown.component.js" -import SlButton from "@shoelace-style/shoelace/dist/components/button/button.component.js" -import SlSelect from "@shoelace-style/shoelace/dist/components/select/select.component.js" -import SlOption from "@shoelace-style/shoelace/dist/components/option/option.component.js" -import SlTag from "@shoelace-style/shoelace/dist/components/tag/tag.component.js" -import { createMessage, type LanguageTag, type Message } from "@inlang/sdk/v2" -import { addSelector } from "../helper/crud/selector/add.js" -import upsertVariant from "../helper/crud/variant/upsert.js" - -// in case an app defines it's own set of shoelace components, prevent double registering -if (!customElements.get("sl-dropdown")) customElements.define("sl-dropdown", SlDropdown) -if (!customElements.get("sl-button")) customElements.define("sl-button", SlButton) -if (!customElements.get("sl-select")) customElements.define("sl-select", SlSelect) -if (!customElements.get("sl-option")) customElements.define("sl-option", SlOption) -if (!customElements.get("sl-tag")) customElements.define("sl-tag", SlTag) - -@customElement("inlang-selector-configurator") -export default class InlangSelectorConfigurator extends LitElement { - static override styles = [ - css` - .button-wrapper { - height: 44px; - display: flex; - align-items: center; - justify-content: center; - } - .dropdown-container { - font-size: 13px; - width: 300px; - background-color: white; - border: 1px solid var(--sl-color-neutral-300); - padding: 16px; - border-radius: 6px; - display: flex; - flex-direction: column; - gap: 16px; - } - sl-select::part(form-control-label) { - font-size: 13px; - color: var(--sl-color-neutral-700); - } - .dropdown-title { - font-size: 14px; - font-weight: 600; - margin: 0; - } - .options-container { - } - .options-title { - font-size: 13px; - color: var(--sl-color-neutral-700); - margin: 0; - padding-bottom: 4px; - } - .options-wrapper { - display: flex; - gap: 4px; - flex-wrap: wrap; - } - .actions { - width: 100%; - display: flex; - flex-direction: column; - gap: 4px; - } - `, - ] - - @property() - inputs: string[] | undefined - - @property() - message?: Message | undefined - - @property() - languageTag: LanguageTag | undefined - - @property() - triggerMessageBundleRefresh: () => void = () => {} - - @property() - addMessage: (newMessage: Message) => void = () => {} - - @state() - private _input: string | undefined - - private _getPluralCategories = (): string[] | undefined => { - return this.languageTag - ? new Intl.PluralRules(this.languageTag).resolvedOptions().pluralCategories - : undefined - } - - private _handleAddSelector = (addVariants: boolean) => { - if (this._input) { - if (!this.message && this.languageTag) { - // create selector in not present message - const newMessage = createMessage({ locale: this.languageTag, text: "" }) - - // add selector - addSelector({ - message: newMessage, - selector: { - type: "expression", - arg: { - type: "variable", - name: this._input, - }, - annotation: { - type: "function", - name: "plural", - options: [], - }, - }, - }) - - if (addVariants) { - this._addVariants({ message: newMessage, variantMatcherArrays: [] }) - } - - this.addMessage(newMessage) - } else if (this.message) { - // get variant matchers arrays - const _variants = structuredClone(this.message ? this.message.variants : []) - const _variantMatcherArrays = _variants.map((variant) => variant.match) - - // add selector - addSelector({ - message: this.message, - selector: { - type: "expression", - arg: { - type: "variable", - name: this._input, - }, - annotation: { - type: "function", - name: "plural", - options: [], - }, - }, - }) - - // add plural options if present - // TODO: for now always present - if (addVariants) { - this._addVariants({ message: this.message, variantMatcherArrays: _variantMatcherArrays }) - } - } - - this.triggerMessageBundleRefresh() - } - } - - private _addVariants = (props: { message: Message; variantMatcherArrays: string[][] }) => { - const _categories = this._getPluralCategories() - - if (_categories) { - if (props.variantMatcherArrays && props.variantMatcherArrays.length > 0) { - for (const variantMatcherArray of props.variantMatcherArrays) { - for (const category of _categories) { - upsertVariant({ - message: props.message, - variant: { - // combine the matches that are already present with the new category -> like a matrix - match: [...variantMatcherArray, category], - pattern: [ - { - type: "text", - value: "", - }, - ], - }, - }) - } - } - } else { - for (const category of _categories) { - upsertVariant({ - message: props.message, - variant: { - // combine the matches that are already present with the new category -> like a matrix - match: [category], - pattern: [ - { - type: "text", - value: "", - }, - ], - }, - }) - } - } - } - } - - override async firstUpdated() { - await this.updateComplete - this._input = this.inputs && this.inputs[0] - } - - override render() { - return html` - -
- -
- -
- ` - } -} - -declare global { - interface HTMLElementTagNameMap { - "inlang-selector-configurator": InlangSelectorConfigurator - } -} diff --git a/inlang/source-code/message-bundle-component/src/stories/inlang-variant.ts b/inlang/source-code/message-bundle-component/src/stories/inlang-variant.ts deleted file mode 100644 index c3100ee2f3..0000000000 --- a/inlang/source-code/message-bundle-component/src/stories/inlang-variant.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { type Variant, type Message, createMessage, type LanguageTag } from "@inlang/sdk/v2" -import { LitElement, css, html } from "lit" -import { customElement, property, state } from "lit/decorators.js" -import upsertVariant from "../helper/crud/variant/upsert.js" -import deleteVariant from "../helper/crud/variant/delete.js" -import { getNewVariantPosition } from "../helper/crud/variant/sort.js" -import type { MessageLintReport } from "@inlang/message-lint-rule" - -import "./inlang-lint-report-tip.js" -import "./inlang-selector-configurator.js" -import variantIsCatchAll from "../helper/crud/variant/isCatchAll.js" -import updateMatch from "../helper/crud/variant/updateMatch.js" - -import SlTag from "@shoelace-style/shoelace/dist/components/tag/tag.component.js" - -// in case an app defines it's own set of shoelace components, prevent double registering -if (!customElements.get("sl-tag")) customElements.define("sl-tag", SlTag) - -@customElement("inlang-variant") -export default class InlangVariant extends LitElement { - static override styles = [ - css` - div { - box-sizing: border-box; - font-size: 13px; - } - :host { - border-top: 1px solid var(--sl-color-neutral-300); - } - :host(:first-child) { - border-top: none; - } - .variant { - position: relative; - min-height: 44px; - width: 100%; - display: flex; - align-items: center; - } - .match { - height: 44px; - width: 120px; - background-color: none; - border-right: 1px solid var(--sl-color-neutral-300); - } - .match::part(base) { - border: none; - border-radius: 0; - min-height: 44px; - } - .match::part(input) { - min-height: 44px; - } - .pattern { - flex: 1; - background-color: none; - height: 44px; - } - .pattern::part(base) { - border: none; - border-radius: 0; - min-height: 44px; - } - .pattern::part(input) { - min-height: 44px; - } - .pattern::part(input)::placeholder { - color: var(--sl-color-neutral-400); - font-size: 13px; - } - .actions { - position: absolute; - top: 0; - right: 0; - height: 44px; - display: flex; - align-items: center; - gap: 6px; - padding-right: 12px; - } - .add-selector { - height: 30px; - padding-right: 8px; - padding-left: 6px; - display: flex; - gap: 4px; - align-items: center; - justify-content: center; - color: var(--sl-color-neutral-600); - border-radius: 4px; - border: 1px solid var(--sl-color-neutral-300); - background-color: var(--sl-color-neutral-0); - cursor: pointer; - font-size: 13px; - } - .add-selector:hover { - color: var(--sl-color-neutral-900); - background-color: var(--sl-color-neutral-200); - border: 1px solid var(--sl-color-neutral-400); - } - .hide-when-not-active { - display: none; - align-items: center; - gap: 6px; - } - sl-button::part(base):hover { - color: var(--sl-color-neutral-900); - background-color: var(--sl-color-neutral-100); - border: 1px solid var(--sl-color-neutral-400); - } - .variant:hover .hide-when-not-active { - display: flex; - } - `, - ] - - @property() - message: Message | undefined - - @property() - languageTag: LanguageTag | undefined - - @property() - variant: Variant | undefined - - @property() - inputs: string[] | undefined - - @property() - lintReports: MessageLintReport[] | undefined - - @property() - addMessage: (newMessage: Message) => void = () => {} - - @property() - triggerMessageBundleRefresh: () => void = () => {} - - @property() - triggerSave: () => void = () => {} - - @state() - private _pattern: string | undefined = undefined - - @state() - private _isActive: boolean = false - - _save = () => { - if (this.message && this.variant && this._pattern) { - // upsert variant - upsertVariant({ - message: this.message, - variant: { - match: this.variant.match, - pattern: [ - { - type: "text", - value: this._pattern, - }, - ], - }, - }) - this.triggerSave() - } else { - // new message - if (this.languageTag && this._pattern) { - //TODO: only text pattern supported - this.addMessage(createMessage({ locale: this.languageTag, text: this._pattern })) - this.triggerSave() - } - } - } - - _delete = () => { - if (this.message && this.variant) { - // upsert variant - deleteVariant({ - message: this.message, - variant: this.variant, - }) - this.triggerSave() - this.triggerMessageBundleRefresh() - } - } - - _updateMatch = (matchIndex: number, value: string) => { - //TODO improve this function - if (this.variant && this.message) { - this._pattern = - this.variant?.pattern - .map((p) => { - if ("value" in p) { - return p.value - } else if (p.type === "expression" && p.arg.type === "variable") { - return p.arg.name - } - return "" - }) - .join(" ") || "" - updateMatch({ - variant: this.variant, - matchIndex: matchIndex, - value, - }) - const variant = structuredClone(this.variant) - //get index of this.varinat in message and delete it - const deleteIndex = this.message.variants.indexOf(this.variant) - this.message.variants.splice(deleteIndex, 1) - - //get sort index of new variant - const newpos = getNewVariantPosition({ - variants: this.message.variants, - newVariant: variant, - }) - - //insert variant at new position - this.message.variants.splice(newpos, 0, variant) - - this._save() - this.triggerMessageBundleRefresh() - } - } - - private get _selectors(): string[] | undefined { - // @ts-ignore - just for prototyping - return this.message ? this.message.selectors.map((selector) => selector.arg.name) : undefined - } - - private get _matches(): string[] | undefined { - // @ts-ignore - just for prototyping - return this._selectors.map((selector) => { - const matchIndex = this._selectors ? this._selectors.indexOf(selector) : undefined - return this.variant && typeof matchIndex === "number" ? this.variant.match[matchIndex] : "" - }) - } - - override render() { - return html`
- ${this.variant && this._matches - ? this._matches.map( - (match, index) => - html` - { - this._updateMatch(index, (e.target as HTMLInputElement).value) - }} - > - ` - ) - : undefined} - { - if ("value" in p) { - return p.value - } else if (p.type === "expression" && p.arg.type === "variable") { - return p.arg.name - } - return "" - }) - .join(" ") - : ""} - @input=${(e: Event) => { - this._pattern = (e.target as HTMLInputElement).value - }} - @sl-blur=${(e: Event) => { - this._pattern = (e.target as HTMLInputElement).value - this._save() - }} - > -
-
- - ${this.message?.selectors.length === 0 || !this.message?.selectors - ? html` -
- - - - Selector -
-
-
` - : ``} - ${this.message && this.variant && !variantIsCatchAll({ variant: this.variant }) - ? html` this._delete()}>Delete` - : ``} -
- ${this.lintReports && - this.lintReports.length > 0 && - this.message?.selectors && - this.message.selectors.length === 0 - ? html`` - : ``} -
-
` - } -} - -declare global { - interface HTMLElementTagNameMap { - "inlang-variant": InlangVariant - } -} diff --git a/inlang/source-code/sdk-v2/.eslintrc.json b/inlang/source-code/sdk-v2/.eslintrc.json new file mode 100644 index 0000000000..2b51924587 --- /dev/null +++ b/inlang/source-code/sdk-v2/.eslintrc.json @@ -0,0 +1,21 @@ +{ + "overrides": [ + { + "files": ["**/*.ts"], + "excludedFiles": ["**/*.test.ts"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": ["node:*"], + "message": "Keep in mind that node API's don't work inside the browser" + } + ] + } + ] + } + } + ] +} diff --git a/inlang/source-code/sdk-v2/LICENSE b/inlang/source-code/sdk-v2/LICENSE new file mode 100644 index 0000000000..ecc0047717 --- /dev/null +++ b/inlang/source-code/sdk-v2/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [2024] [Opral US Inc.] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/inlang/source-code/sdk-v2/README.md b/inlang/source-code/sdk-v2/README.md new file mode 100644 index 0000000000..259198566e --- /dev/null +++ b/inlang/source-code/sdk-v2/README.md @@ -0,0 +1,16 @@ +Developer-first localization infrastructure that is built on lix. Your lix is the single source of truth for localization for collaboration and automation. + +
+

+ +

+

+ + Discussions · Twitter +

+
+ +# @inlang/sdk + +The core module bundles "sdk" modules that depend on each other and is everything one needs to build [plugins](https://inlang.com/documentation/plugin) or entire [apps](https://inlang.com/documentation/build-app) on inlang. diff --git a/inlang/source-code/sdk-v2/package.json b/inlang/source-code/sdk-v2/package.json new file mode 100644 index 0000000000..9dab3734a6 --- /dev/null +++ b/inlang/source-code/sdk-v2/package.json @@ -0,0 +1,66 @@ +{ + "name": "@inlang/sdk-v2", + "type": "module", + "version": "0.0.1", + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "homepage": "https://inlang.com/documentation/sdk", + "repository": { + "type": "git", + "url": "https://github.com/opral/inlang-message-sdk" + }, + "exports": { + ".": "./dist/index.js", + "./test-utilities": "./dist/test-utilities/index.js", + "./lint": "./dist/lint/index.js" + }, + "files": [ + "./dist", + "./src" + ], + "scripts": { + "build": "pnpm run prepare-env-variables && tsc --build", + "dev": "pnpm run prepare-env-variables && tsc --watch", + "prepare-env-variables": "node ./src/env-variables/createIndexFile.js", + "test": "pnpm run prepare-env-variables && tsc --noEmit && vitest run --passWithNoTests --coverage", + "lint": "eslint ./src --fix", + "format": "prettier ./src --write", + "clean": "rm -rf ./dist ./node_modules" + }, + "engines": { + "node": ">=18.0.0" + }, + "dependencies": { + "@inlang/result": "workspace:*", + "@inlang/module": "workspace:*", + "@inlang/json-types": "workspace:*", + "@inlang/translatable": "workspace:*", + "@inlang/plugin": "workspace:*", + "@lix-js/fs": "workspace:*", + "@sqlite.org/sqlite-wasm": "^3.46.0-build2", + "deepmerge-ts": "^5.1.0", + "dedent": "1.5.1", + "kysely": "^0.27.4", + "sqlocal": "^0.10.1", + "rxjs": "7.8.1", + "@sinclair/typebox": "^0.31.17", + "uuid": "^10.0.0", + "murmurhash3js": "^3.0.1" + }, + "devDependencies": { + "@types/debug": "^4.1.12", + "@types/murmurhash3js": "^3.0.7", + "@types/throttle-debounce": "5.0.0", + "@types/uuid": "^9.0.8", + "@vitest/coverage-v8": "^0.33.0", + "@vitest/web-worker": "^1.6.0", + "jsdom": "22.1.0", + "patch-package": "6.5.1", + "tsd": "^0.25.0", + "typescript": "^5.5.2", + "vite": "^5.3.2", + "vitest": "^0.33.0" + } +} diff --git a/inlang/source-code/sdk-v2/sample-app/.gitignore b/inlang/source-code/sdk-v2/sample-app/.gitignore new file mode 100644 index 0000000000..ac43eed893 --- /dev/null +++ b/inlang/source-code/sdk-v2/sample-app/.gitignore @@ -0,0 +1,26 @@ +rxdb-local.tgz + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/inlang/source-code/sdk-v2/sample-app/README.md b/inlang/source-code/sdk-v2/sample-app/README.md new file mode 100644 index 0000000000..ad7730d68d --- /dev/null +++ b/inlang/source-code/sdk-v2/sample-app/README.md @@ -0,0 +1,10 @@ +# Slot storage, git, RxDB in Vite Typescript Demo + +This is a quick crud app that showcases the slot file with query capabilities of rxdb + +# Try it out +1. replace the github token in src/storage/db.ts +2. create a repo you have access to and and set the repo url in src/storage/db.ts +3. run `pnpm install` +4. run `pnpm run proxy` to start the cors proxy localy +5. run `pnpm run dev` to start the app diff --git a/inlang/source-code/sdk-v2/sample-app/index.html b/inlang/source-code/sdk-v2/sample-app/index.html new file mode 100644 index 0000000000..5d93aee715 --- /dev/null +++ b/inlang/source-code/sdk-v2/sample-app/index.html @@ -0,0 +1,96 @@ + + + + + + + + + + Message Format + rxdb + Slotstorage + lix + + +
+ + + diff --git a/inlang/source-code/sdk-v2/sample-app/package.json b/inlang/source-code/sdk-v2/sample-app/package.json new file mode 100644 index 0000000000..d445a123f9 --- /dev/null +++ b/inlang/source-code/sdk-v2/sample-app/package.json @@ -0,0 +1,40 @@ +{ + "name": "inlang-sdk-v2-sampleapp", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "test": "pnpm run build && concurrently \"pnpm run preview\" \"pnpm run test:e2e\" --kill-others --success first", + "proxy": "node ./proxy/index.js" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "async-test-util": "2.5.0", + "concurrently": "8.2.2", + "copyfiles": "2.4.1", + "testcafe": "3.6.0", + "typescript": "5.4.5", + "vite": "5.2.12", + "vite-plugin-top-level-await": "1.4.1" + }, + "dependencies": { + "process": "^0.11.10", + "express": "^4.19.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-window": "^1.8.10", + "@lit/react": "^1.0.5", + "vite-plugin-node-polyfills": "0.17.0", + "@esbuild-plugins/node-globals-polyfill": "^0.2.3", + "@esbuild-plugins/node-modules-polyfill": "^0.2.2", + "comlink": "^4.4.1", + "@types/react-dom": "^18.3.0", + "@inlang/sdk-v2": "workspace:*", + "@inlang/bundle-component": "workspace:*", + "@inlang/settings-component": "workspace:*" + } +} diff --git a/inlang/source-code/sdk-v2/sample-app/src/MessageBundleView.tsx b/inlang/source-code/sdk-v2/sample-app/src/MessageBundleView.tsx new file mode 100644 index 0000000000..6289186eb4 --- /dev/null +++ b/inlang/source-code/sdk-v2/sample-app/src/MessageBundleView.tsx @@ -0,0 +1,105 @@ +import React, { useEffect, useState } from "react" +import { createComponent } from "@lit/react" +import { InlangBundle } from "@inlang/bundle-component" +import { ProjectSettings2 } from "../../src/types/project-settings.js" +import { InlangProject } from "../../src/types/project.js" +import { LintReport } from "../../src/types/lint.js" +import { LanguageTag } from "../../src/types/language-tag.js" +import { NestedBundle, NestedMessage, Variant } from "../../src/index.js" + +export const MessageBundleComponent = createComponent({ + tagName: "inlang-bundle", + elementClass: InlangBundle, + react: React, + events: { + changeMessageBundle: "change-message-bundle", + insertMessage: "insert-message", + updateMessage: "update-message", + + insertVariant: "insert-variant", + updateVariant: "update-variant", + deleteVariant: "delete-variant", + fixLint: "fix-lint", + }, +}) +type MessageBundleViewProps = { + bundle: NestedBundle // TODO SDK2 make SDK Bundle a reactive query that delivers the bundle instead + // reports: Subject + projectSettings: ProjectSettings2 + project: InlangProject + filteredLocales: LanguageTag[] +} +export function MessageBundleView({ + bundle, + // reports, + projectSettings, + project, + filteredLocales, +}: MessageBundleViewProps) { + const [currentBundle, setBundle] = useState(bundle) + + useEffect(() => { + // Assume bundle$ is an RxJS Subject or Observable + // const subscription = bundle.$.subscribe((updatedBundle) => { + // console.log("updateing Bundle from subscribe", updatedBundle) + // // Handle the bundle update + // setBundle(updatedBundle) + // }) + // return () => { + // // Clean up the subscription when the component unmounts or when bundle changes + // subscription.unsubscribe() + // } + }, [bundle]) + + const onBundleChange = (messageBundle: { detail: { argument: NestedBundle } }) => { + // eslint-disable-next-line no-console + // TODO SDK-V2 check how we update the bundle in v2 sql + // project.messageBundleCollection?.upsert(messageBundle.detail.argument) + } + + const onMesageInsert = (event: { detail: { argument: { message: NestedMessage } } }) => { + const insertedMessage = event.detail.argument.message + const dbPromise = project.message.insert(insertedMessage).execute() + } + const onMesageUpdate = (event: { detail: { argument: { message: NestedMessage } } }) => { + const updatedMessage = event.detail.argument.message + const dbPromise = project.message.update(updatedMessage).execute() + } + const onVariantInsert = (event: { detail: { argument: { variant: Variant } } }) => { + const insertedVariant = event.detail.argument.variant + const dbPromise = project.variant.insert(insertedVariant).execute() + } + const onVariantUpdate = (event: { detail: { argument: { variant: Variant } } }) => { + const updatedVariant = event.detail.argument.variant + const dbPromise = project.variant.update(updatedVariant).execute() + } + const onVariantDelete = (event: { detail: { argument: { variant: Variant } } }) => { + const deletedVariant = event.detail.argument.variant + const dbPromise = project.variant.delete(deletedVariant).execute() + } + + console.log(currentBundle) + + return ( + 0 ? filteredLocales : undefined} + fixLint={(e: any) => { + const { fix, lintReport } = e.detail.argument as { + fix: string + lintReport: LintReport + } + + project.fix(lintReport, { title: fix }) + }} + /> + ) +} diff --git a/inlang/source-code/sdk-v2/sample-app/src/PageView.tsx b/inlang/source-code/sdk-v2/sample-app/src/PageView.tsx new file mode 100644 index 0000000000..74cc3176ca --- /dev/null +++ b/inlang/source-code/sdk-v2/sample-app/src/PageView.tsx @@ -0,0 +1,95 @@ +import { useState } from "react" +import { loadProjectOpfs, newProjectOpfs } from "../../src/index.js" +import { MainView } from "./mainView.js" + +const projectPath = "workingCopy.inlang" + +export function PageView() { + const [loadingProjectState, setLoadingProjectState] = useState("") + const [currentProject, setCurrentProject] = useState< + Awaited> | undefined + >(undefined) + + const newProject = async () => { + await newProjectOpfs({ + inlangFolderPath: projectPath, + }) + await loadProject() + } + + const loadProject = async () => { + setLoadingProjectState("Loading") + try { + const loadedProject = await loadProjectOpfs({ + inlangFolderPath: projectPath, + }) + setCurrentProject(loadedProject) + } catch (e) { + setLoadingProjectState((e as any).message) + return + } + + setLoadingProjectState("") + } + + return ( +
+

{"Fink 2"}

+
+ + +
+ {loadingProjectState} + {currentProject && ( + <> +
+
+ {/* + +
+
+ */} +
+
+ +
+ {/*
+