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