Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 18 additions & 13 deletions .github/workflows/cypress.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ./
Expand All @@ -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

Expand Down
22 changes: 12 additions & 10 deletions check-translations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
}
}

Expand Down
23 changes: 23 additions & 0 deletions doctor.config.json
Original file line number Diff line number Diff line change
@@ -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"],
},
],
},
}
4 changes: 3 additions & 1 deletion src/components/designSystem/Filters/useFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,16 @@ 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

// 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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')

/**
* Shared Mention schema — markdown storage and resolution-aware renderHTML.
* Used by both the editor (which adds addNodeView + suggestion) and headless consumers.
Expand All @@ -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) =>
`<span data-type="mention" data-id="${id}" data-label="${label}" class="variable-mention">@${label}</span>`,
`<span data-type="mention" data-id="${escapeHtml(id)}" data-label="${escapeHtml(label)}" class="variable-mention">@${escapeHtml(label)}</span>`,
)
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
11 changes: 2 additions & 9 deletions src/components/form/MultipleComboBox/MultipleComboBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,22 +152,15 @@ 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

// 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 (
<Chip
{...tagOptions}
className="my-2 ml-2 mr-0"
key={tagOptions.key}
label={labelToUse}
/>
)
return <Chip key={key} {...tagProps} className="my-2 ml-2 mr-0" label={labelToUse} />
})
}}
componentsProps={{
Expand Down
2 changes: 1 addition & 1 deletion src/components/form/Radio/RadioGroupField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,11 @@ export const RadioGroupField: FC<RadioGroupFieldProps> = ({
({ value: optionValue, label: optionLabel, disabled: optionDisabled, ...props }) => {
return (
<RadioField
key={`radio-group-field-${optionValue}`}
{...props}
name={name}
formikProps={formikProps}
disabled={disabled || optionDisabled}
key={`radio-group-field-${optionValue}`}
label={optionLabel ?? optionValue}
labelVariant={optionLabelVariant}
value={optionValue}
Expand Down
2 changes: 1 addition & 1 deletion src/components/form/Radio/RadioGroupFieldForTanstack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,10 @@ const RadioGroupField: FC<RadioGroupFieldProps> = ({
({ value: optionValue, label: optionLabel, disabled: optionDisabled, ...props }) => {
return (
<Radio
key={`radio-group-field-${optionValue}`}
{...props}
name={field.name}
disabled={disabled || optionDisabled}
key={`radio-group-field-${optionValue}`}
label={optionLabel ?? String(optionValue)}
labelVariant={optionLabelVariant}
value={optionValue}
Expand Down
6 changes: 3 additions & 3 deletions src/components/form/TextInput/TextInputFieldForTanstack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ const TextInputField = ({
const { translate } = useInternationalization()

const errorMap = useStore(field.store, (state) => 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
Expand Down
20 changes: 15 additions & 5 deletions src/components/graphs/Invoices.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, NonNullable<typeof data>[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({
Expand Down
10 changes: 8 additions & 2 deletions src/components/invoices/utils/getMostRecentPaymentMethodId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PaymentWithMethodId | undefined>((mostRecent, payment) => {
if (!mostRecent) return payment

return new Date(payment.createdAt).getTime() > new Date(mostRecent.createdAt).getTime()
? payment
: mostRecent
}, undefined)

return paymentWithMethodId?.paymentMethodId ?? undefined
}
8 changes: 6 additions & 2 deletions src/components/plans/chargeAccordion/ChargeFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,14 @@ export const ChargeFilter = memo(
const filterValues: BasicComboBoxData[] = useMemo(() => {
if (!billableMetricFilters) return []

const selectedFilterValues = new Set(filter.values)

return billableMetricFilters.reduce<BasicComboBoxData[]>((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
}
Expand Down Expand Up @@ -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}`,
)
Expand All @@ -103,6 +105,8 @@ export const ChargeFilter = memo(
elementToFocus.scrollIntoView({ behavior: 'smooth', block: 'center' })
elementToFocus.click()
}, 0)

return () => clearTimeout(timeoutId)
}
}, [showComboBox])

Expand Down
6 changes: 5 additions & 1 deletion src/hooks/customer/useAddSubscription.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/hooks/forms/useFieldError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ export function useFieldError<TNoBoolean extends boolean | undefined = undefined
const { translate } = useInternationalization()

const errorMap = useStore(field.store, (state) => 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
Expand Down
2 changes: 1 addition & 1 deletion src/pages/CustomerInvoiceRegenerate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
5 changes: 2 additions & 3 deletions src/pages/createCreditNote/common/useCreateCreditNote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion src/pages/createCustomers/mappers/mapFromApiToForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading