diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index 3e4fc61fc0..b7ff9ca231 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -20,6 +20,24 @@ jobs: ref: ${{ github.event.inputs.api_branch }} path: api + - name: Install pnpm + uses: pnpm/action-setup@v6.0.9 + + - name: Set up Node.js + uses: actions/setup-node@v6.4.0 + with: + node-version-file: package.json + cache: "pnpm" + cache-dependency-path: ./pnpm-lock.yaml + + # Install dependencies before any CI secret is exposed to a step env, and + # with lifecycle scripts disabled, so a malicious package script cannot run + # arbitrary code or read secrets during install. Cypress installs its own + # binary in a later step and the app is built inside Docker, so no host + # postinstall step is needed here. + - name: Install dependencies + run: pnpm install --ignore-scripts + - name: Build Front local image run: | docker build -t getlago/front:ci ./ @@ -38,19 +56,6 @@ jobs: run: | docker compose -f ./ci/docker-compose.ci.yml --env-file ./.env up -d db redis api front - - name: Install pnpm - uses: pnpm/action-setup@v6.0.9 - - - name: Set up Node.js - uses: actions/setup-node@v6.4.0 - with: - node-version-file: package.json - cache: "pnpm" - cache-dependency-path: ./pnpm-lock.yaml - - - name: Install dependencies - run: pnpm install - - name: Populate db run: docker compose -f ./ci/docker-compose.ci.yml --env-file ./.env exec api bundle exec rails db:seed diff --git a/check-translations.js b/check-translations.js index 0f5b1a3535..5f25d4f559 100644 --- a/check-translations.js +++ b/check-translations.js @@ -103,26 +103,28 @@ function main() { console.log(`${colors.green}✓ ${result.file}${colors.reset}`) console.log(` Total keys: ${result.totalKeys} (matches base)\n`) } else { + const { missingKeys, extraKeys } = result + console.log(`${colors.red}✗ ${result.file}${colors.reset}`) console.log(` Total keys: ${result.totalKeys} (base has ${baseKeys.size})`) - if (result.missingKeys.length > 0) { - console.log(` ${colors.red}Missing keys: ${result.missingKeys.length}${colors.reset}`) - result.missingKeys.slice(0, 10).forEach((key) => { + if (missingKeys.length > 0) { + console.log(` ${colors.red}Missing keys: ${missingKeys.length}${colors.reset}`) + missingKeys.slice(0, 10).forEach((key) => { console.log(` - ${key}`) }) - if (result.missingKeys.length > 10) { - console.log(` ... and ${result.missingKeys.length - 10} more`) + if (missingKeys.length > 10) { + console.log(` ... and ${missingKeys.length - 10} more`) } } - if (result.extraKeys.length > 0) { - console.log(` ${colors.yellow}Extra keys: ${result.extraKeys.length}${colors.reset}`) - result.extraKeys.slice(0, 10).forEach((key) => { + if (extraKeys.length > 0) { + console.log(` ${colors.yellow}Extra keys: ${extraKeys.length}${colors.reset}`) + extraKeys.slice(0, 10).forEach((key) => { console.log(` - ${key}`) }) - if (result.extraKeys.length > 10) { - console.log(` ... and ${result.extraKeys.length - 10} more`) + if (extraKeys.length > 10) { + console.log(` ... and ${extraKeys.length - 10} more`) } } diff --git a/doctor.config.json b/doctor.config.json new file mode 100644 index 0000000000..d122db1ea6 --- /dev/null +++ b/doctor.config.json @@ -0,0 +1,23 @@ +{ + // React Doctor configuration. JSON5 syntax (comments + trailing commas allowed). + // Docs: https://www.react.doctor/docs/configuration/config-files + "ignore": { + "overrides": [ + { + // Manually-run team CLI that validates translation files. It is an + // entry point in its own right (invoked by hand), not imported from the + // app, so deslop cannot see it as reachable. False positive. + "files": ["check-translations.js"], + "rules": ["deslop/unused-file"], + }, + { + // Reachable via a lazy route: CustomerRoutes.tsx does + // `lazyLoad(() => import(`~/pages/CustomerInvoiceVoid`))`. deslop cannot + // resolve the template-literal path alias, so it reports it unused. + // False positive. + "files": ["src/pages/CustomerInvoiceVoid.tsx"], + "rules": ["deslop/unused-file"], + }, + ], + }, +} diff --git a/src/components/designSystem/Filters/useFilters.ts b/src/components/designSystem/Filters/useFilters.ts index b042c4d07e..9bb03b944a 100644 --- a/src/components/designSystem/Filters/useFilters.ts +++ b/src/components/designSystem/Filters/useFilters.ts @@ -20,6 +20,8 @@ export const useFilters = () => { const localKeyWithPrefix = (key: string) => keyWithPrefix(key, prefix) const removeExistingFilters = () => { + const availableFilterSet = new Set(context.availableFilters) + // Only remove the filters from the URL that are currently applied and are removable (availableFilters) for (const search in searchParamsObject) { const key = keyWithoutPrefix(search) as AvailableFiltersEnum @@ -27,7 +29,7 @@ export const useFilters = () => { // if value is part of the static filters, reset to default static value if (context.staticFilters?.[key]) { searchParams.set(search, context.staticFilters[key]) - } else if (context.availableFilters.includes(key)) { + } else if (availableFilterSet.has(key)) { // otherwise, remove the filter from the URL searchParams.delete(search) } diff --git a/src/components/designSystem/RichTextEditor/PricingBlock/PricingBlockView.tsx b/src/components/designSystem/RichTextEditor/PricingBlock/PricingBlockView.tsx index 5cb1fe10d6..714e32d388 100644 --- a/src/components/designSystem/RichTextEditor/PricingBlock/PricingBlockView.tsx +++ b/src/components/designSystem/RichTextEditor/PricingBlock/PricingBlockView.tsx @@ -85,7 +85,11 @@ export const PricingBlockView = ({ node, updateAttributes }: NodeViewProps) => { const isEmpty = entityIds.length === 0 const lookupIds = localEntityIds.length > 0 ? localEntityIds : entityIds - const resolvedEntities = lookupIds.map((id) => entities[id]).filter(Boolean) + const resolvedEntities = lookupIds.flatMap((id) => { + const entity = entities[id] + + return entity ? [entity] : [] + }) const hasResolved = resolvedEntities.length > 0 // Preview mode: dispatch by pricing type diff --git a/src/components/designSystem/RichTextEditor/extensions/Mention.schema.ts b/src/components/designSystem/RichTextEditor/extensions/Mention.schema.ts index 0cad204f52..6774752594 100644 --- a/src/components/designSystem/RichTextEditor/extensions/Mention.schema.ts +++ b/src/components/designSystem/RichTextEditor/extensions/Mention.schema.ts @@ -1,5 +1,18 @@ import Mention, { type MentionOptions } from '@tiptap/extension-mention' +/** + * Escapes HTML-significant characters so a mention `id`/`label` parsed from + * stored markdown cannot break out of the attribute/text context and inject + * markup when written back via innerHTML. + */ +const escapeHtml = (value: string): string => + value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') + /** * Shared Mention schema — markdown storage and resolution-aware renderHTML. * Used by both the editor (which adds addNodeView + suggestion) and headless consumers. @@ -26,10 +39,15 @@ export const MentionSchema = Mention.extend({ }, parse: { updateDOM(element: HTMLElement) { + // Safe: both interpolated captures pass through escapeHtml, so no + // markup can be injected. The sink is flagged only because the + // sanitizer is applied inside the replacer rather than wrapping the + // whole expression, which the static check can't see. + // react-doctor-disable-next-line react-doctor/dangerous-html-sink element.innerHTML = element.innerHTML.replaceAll( /\{(\w+)\|([^}]+)\}/g, (_match: string, id: string, label: string) => - `@${label}`, + `@${escapeHtml(label)}`, ) }, }, diff --git a/src/components/designSystem/RichTextEditor/extensions/TableCommands.ts b/src/components/designSystem/RichTextEditor/extensions/TableCommands.ts index 1a1f07bff5..09b5c539bd 100644 --- a/src/components/designSystem/RichTextEditor/extensions/TableCommands.ts +++ b/src/components/designSystem/RichTextEditor/extensions/TableCommands.ts @@ -65,12 +65,13 @@ const resolveRowAndCol = ( for (let d = tableDepth + 1; d <= $pos.depth; d++) { const ancestor = $pos.node(d) + const nodeName = ancestor.type.name - if (ancestor.type.name === 'tableRow') { + if (nodeName === 'tableRow') { rowIndex = $pos.index(tableDepth) rowPos = $pos.before(d) } - if (ancestor.type.name === 'tableCell' || ancestor.type.name === 'tableHeader') { + if (nodeName === 'tableCell' || nodeName === 'tableHeader') { colIndex = $pos.index(d - 1) } } diff --git a/src/components/form/MultipleComboBox/MultipleComboBox.tsx b/src/components/form/MultipleComboBox/MultipleComboBox.tsx index bdeda85442..25c9f93659 100644 --- a/src/components/form/MultipleComboBox/MultipleComboBox.tsx +++ b/src/components/form/MultipleComboBox/MultipleComboBox.tsx @@ -152,7 +152,7 @@ export const MultipleComboBox = ({ } return tagValues.map((option, index) => { - const tagOptions = getTagProps({ index }) + const { key, ...tagProps } = getTagProps({ index }) // Happens when `freeSolo` is true and user types a value that is not in the list and press enter const optionValue = typeof option === 'string' ? option : option.value const optionLabel = typeof option === 'string' ? option : option.label || optionValue @@ -160,14 +160,7 @@ export const MultipleComboBox = ({ // Happens when `freeSolo` is true and we click on the option instead of submitting by using the enter key const labelToUse = option.customValue ? optionValue : optionLabel - return ( - - ) + return }) }} componentsProps={{ diff --git a/src/components/form/Radio/RadioGroupField.tsx b/src/components/form/Radio/RadioGroupField.tsx index 97cf500364..6b2d451e08 100644 --- a/src/components/form/Radio/RadioGroupField.tsx +++ b/src/components/form/Radio/RadioGroupField.tsx @@ -68,11 +68,11 @@ export const RadioGroupField: FC = ({ ({ value: optionValue, label: optionLabel, disabled: optionDisabled, ...props }) => { return ( = ({ ({ value: optionValue, label: optionLabel, disabled: optionDisabled, ...props }) => { return ( state.meta.errorMap) - const allErrors = useStore(field.store, (state) => state.meta.errors) - .map((e) => e.message) - .filter(Boolean) + const allErrors = useStore(field.store, (state) => state.meta.errors).flatMap((e) => + e.message ? [e.message] : [], + ) // Filter errors if showOnlyErrors is provided const filteredErrors = showOnlyErrors diff --git a/src/components/graphs/Invoices.tsx b/src/components/graphs/Invoices.tsx index 2282465c87..f9350585a0 100644 --- a/src/components/graphs/Invoices.tsx +++ b/src/components/graphs/Invoices.tsx @@ -77,12 +77,22 @@ export const fillInvoicesDataPerMonthForPaymentStatus = ( const lastTwelveMonths = getLastTwelveMonthsNumbersUntilNow() const res = [] + // Index the matching-status rows by formatted month once, so the per-month + // loop below is O(1) lookups instead of a full scan each iteration. + const dataByMonth = new Map[number]>() + + for (const d of data ?? []) { + if (d.paymentStatus !== paymentStatus) continue + + const monthKey = DateTime.fromISO(d.month).toFormat(GRAPH_YEAR_MONTH_DATE_FORMAT) + + if (!dataByMonth.has(monthKey)) { + dataByMonth.set(monthKey, d) + } + } + for (const month of lastTwelveMonths) { - const existingMonthData = data?.find( - (d) => - d.paymentStatus === paymentStatus && - DateTime.fromISO(d.month).toFormat(GRAPH_YEAR_MONTH_DATE_FORMAT) === month, - ) + const existingMonthData = dataByMonth.get(month) if (existingMonthData) { res.push({ diff --git a/src/components/invoices/utils/getMostRecentPaymentMethodId.ts b/src/components/invoices/utils/getMostRecentPaymentMethodId.ts index f2784301e6..4ef7fc1278 100644 --- a/src/components/invoices/utils/getMostRecentPaymentMethodId.ts +++ b/src/components/invoices/utils/getMostRecentPaymentMethodId.ts @@ -17,9 +17,15 @@ export const getMostRecentPaymentMethodId = ( return undefined } - const paymentWithMethodId = [...payments] + const paymentWithMethodId = payments .filter((payment) => !!payment.paymentMethodId) - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0] + .reduce((mostRecent, payment) => { + if (!mostRecent) return payment + + return new Date(payment.createdAt).getTime() > new Date(mostRecent.createdAt).getTime() + ? payment + : mostRecent + }, undefined) return paymentWithMethodId?.paymentMethodId ?? undefined } diff --git a/src/components/plans/chargeAccordion/ChargeFilter.tsx b/src/components/plans/chargeAccordion/ChargeFilter.tsx index f88ac0d977..a1071fc992 100644 --- a/src/components/plans/chargeAccordion/ChargeFilter.tsx +++ b/src/components/plans/chargeAccordion/ChargeFilter.tsx @@ -58,12 +58,14 @@ export const ChargeFilter = memo( const filterValues: BasicComboBoxData[] = useMemo(() => { if (!billableMetricFilters) return [] + const selectedFilterValues = new Set(filter.values) + return billableMetricFilters.reduce((acc, cur) => { const parentKeyStrigified = transformFilterObjectToString(cur.key) let hasAnyChildKeySelected = false for (const v of cur.values) { - if (filter.values.includes(transformFilterObjectToString(cur.key, v))) { + if (selectedFilterValues.has(transformFilterObjectToString(cur.key, v))) { hasAnyChildKeySelected = true break } @@ -92,7 +94,7 @@ export const ChargeFilter = memo( useEffect(() => { if (showComboBox) { // Focus filter combobox and show options - setTimeout(() => { + const timeoutId = setTimeout(() => { const elements = document.querySelectorAll( `.${SEARCH_FILTER_FOR_CHARGE_CLASSNAME} .${MUI_INPUT_BASE_ROOT_CLASSNAME}`, ) @@ -103,6 +105,8 @@ export const ChargeFilter = memo( elementToFocus.scrollIntoView({ behavior: 'smooth', block: 'center' }) elementToFocus.click() }, 0) + + return () => clearTimeout(timeoutId) } }, [showComboBox]) diff --git a/src/hooks/customer/useAddSubscription.tsx b/src/hooks/customer/useAddSubscription.tsx index a82549b61b..d47c5e569e 100644 --- a/src/hooks/customer/useAddSubscription.tsx +++ b/src/hooks/customer/useAddSubscription.tsx @@ -241,8 +241,12 @@ export const buildPlanOverridesInput = ( const changedUnits: Array<{ id: string; units: string }> = [] + const baselineFixedChargeById = new Map( + (baselineFixedCharges ?? []).map((fixedCharge) => [fixedCharge.id, fixedCharge]), + ) + for (const charge of currentFixedCharges ?? []) { - const original = baselineFixedCharges?.find((fixedCharge) => fixedCharge.id === charge.id) + const original = baselineFixedChargeById.get(charge.id) // No baseline match → can't prove it's units-only, send the full payload. if (!original) return current diff --git a/src/hooks/forms/useFieldError.ts b/src/hooks/forms/useFieldError.ts index cda35302d5..078e0b9058 100644 --- a/src/hooks/forms/useFieldError.ts +++ b/src/hooks/forms/useFieldError.ts @@ -34,9 +34,9 @@ export function useFieldError state.meta.errorMap) - const allErrors = useStore(field.store, (state) => state.meta.errors) - .map((e) => e.message) - .filter(Boolean) + const allErrors = useStore(field.store, (state) => state.meta.errors).flatMap((e) => + e.message ? [e.message] : [], + ) // Filter errors if showOnlyErrors is provided const filteredErrors = showOnlyErrors diff --git a/src/pages/CustomerInvoiceRegenerate.tsx b/src/pages/CustomerInvoiceRegenerate.tsx index 7dd5c711f1..25bb8fc725 100644 --- a/src/pages/CustomerInvoiceRegenerate.tsx +++ b/src/pages/CustomerInvoiceRegenerate.tsx @@ -137,7 +137,7 @@ const CustomerInvoiceRegenerate = () => { useEffect(() => { if (fullFees?.length && !hasInitializedFees.current) { // Deep clone to preserve original data independent of Apollo cache mutations - originalFeesRef.current = JSON.parse(JSON.stringify(fullFees)) + originalFeesRef.current = structuredClone(fullFees) setFees(fullFees) hasInitializedFees.current = true } diff --git a/src/pages/createCreditNote/common/useCreateCreditNote.ts b/src/pages/createCreditNote/common/useCreateCreditNote.ts index 99db5be879..649003d37f 100644 --- a/src/pages/createCreditNote/common/useCreateCreditNote.ts +++ b/src/pages/createCreditNote/common/useCreateCreditNote.ts @@ -290,12 +290,11 @@ export const useCreateCreditNote: () => UseCreateCreditNoteReturn = () => { (fee) => !trueUpFeeIds?.includes(fee?.id), ) const newFees = [] + const unorderedDataById = new Map(unorderedData.map((fee) => [fee.id, fee])) for (const currentFee of feesWithoutTrueUpOnes || []) { if (currentFee?.trueUpFee?.id) { - const relatedTrueUpFee = unorderedData.find( - (fee) => fee.id === currentFee.trueUpFee?.id, - ) + const relatedTrueUpFee = unorderedDataById.get(currentFee.trueUpFee.id) newFees.push(currentFee, relatedTrueUpFee) } else { diff --git a/src/pages/createCustomers/mappers/mapFromApiToForm.ts b/src/pages/createCustomers/mappers/mapFromApiToForm.ts index 86498563e7..fe1ad52d8f 100644 --- a/src/pages/createCustomers/mappers/mapFromApiToForm.ts +++ b/src/pages/createCustomers/mappers/mapFromApiToForm.ts @@ -47,7 +47,10 @@ export const mapFromApiToForm = ( customer.shippingAddress?.country, ] - return billingAddress.every((value, index) => value === shippingAddress[index]) + return ( + billingAddress.length === shippingAddress.length && + billingAddress.every((value, index) => value === shippingAddress[index]) + ) } // Should only have one between xero and netsuite