Skip to content
Merged
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
14 changes: 14 additions & 0 deletions packages/app/src/modules/my-space/CompanyInfoBanner.module.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
.banner {
background-color: var(--background-alt-blue-france);
}

.infoRow {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
align-items: center;
}

.datapoint {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin: 0;
}
68 changes: 32 additions & 36 deletions packages/app/src/modules/my-space/CompanyInfoBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Breadcrumb } from "~/modules/layout";

import { MODAL_ID as COMPANY_EDIT_MODAL_ID } from "./CompanyEditModal";
import styles from "./CompanyInfoBanner.module.scss";
import { formatAddress } from "./formatAddress";
import { formatSiren } from "./formatSiren";
import { StatusBadge } from "./StatusBadge";
import type { CompanyDetail } from "./types";
Expand All @@ -15,7 +16,7 @@ export function CompanyInfoBanner({ company }: Props) {
const currentYear = getCurrentYear();

return (
<div className={`fr-py-4w ${styles.banner}`}>
<div className={`fr-pt-3w fr-pb-4w ${styles.banner}`}>
<div className="fr-container">
<Breadcrumb
items={[
Expand All @@ -24,59 +25,54 @@ export function CompanyInfoBanner({ company }: Props) {
]}
/>

<div className="fr-mt-3w">
<div className="fr-mt-4w">
<div className="fr-grid-row fr-grid-row--middle fr-mb-1w">
<div className="fr-col">
<h2 className="fr-mb-0">{company.name}</h2>
<h2 className="fr-h4 fr-mb-0">{company.name}</h2>
</div>
<div className="fr-col-auto">
<button
aria-controls={COMPANY_EDIT_MODAL_ID}
className="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-icon-edit-line fr-btn--icon-left"
className="fr-btn fr-btn--tertiary-no-outline fr-icon-edit-line fr-btn--icon-left"
data-fr-opened="false"
type="button"
>
Modifier les informations
Modifier
</button>
</div>
</div>

<p className="fr-text--bold fr-mb-1w">{formatSiren(company.siren)}</p>

{company.address && (
<p className="fr-text--bold fr-mb-1w">{company.address}</p>
)}
<div className={`${styles.infoRow} fr-mb-1w`}>
<p className={styles.datapoint}>
SIREN : <strong>{formatSiren(company.siren)}</strong>
</p>
{company.address && (
<p className={styles.datapoint}>
Adresse : <strong>{formatAddress(company.address)}</strong>
</p>
)}
</div>

<div className="fr-grid-row fr-grid-row--gutters fr-mt-1w">
<div className={styles.infoRow}>
{company.nafCode && (
<div className="fr-col-auto">
<p className="fr-mb-0">
Code NAF : <strong>{company.nafCode}</strong>
</p>
</div>
<p className={styles.datapoint}>
Code NAF : <strong>{company.nafCode}</strong>
</p>
)}
{company.workforce !== null && (
<div className="fr-col-auto">
<p className="fr-mb-0">
Effectif annuel moyen en {currentYear} :{" "}
<strong>{company.workforce}</strong>
</p>
</div>
)}
<div className="fr-col-auto">
<p className="fr-mb-0 fr-flex fr-flex--align-center">
Existence d'un CSE :{" "}
{company.hasCse !== null ? (
<strong className="fr-ml-1v">
{company.hasCse ? "Oui" : "Non"}
</strong>
) : (
<span className="fr-ml-1v">
<StatusBadge status="to_complete" />
</span>
)}
<p className={styles.datapoint}>
Effectif annuel moyen en {currentYear} :{" "}
<strong>{company.workforce}</strong>
</p>
</div>
)}
<p className={styles.datapoint}>
Existence d'un CSE :{" "}
{company.hasCse !== null ? (
<strong>{company.hasCse ? "Oui" : "Non"}</strong>
) : (
<StatusBadge status="to_complete" />
)}
</p>
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,11 @@ describe("CompanyInfoBanner", () => {
it("renders the address when provided", () => {
render(
<CompanyInfoBanner
company={{ ...baseCompany, address: "12 rue de Paris, 75001 Paris" }}
company={{ ...baseCompany, address: "12 RUE DE PARIS, 75001 PARIS" }}
/>,
);
expect(
screen.getByText("12 rue de Paris, 75001 Paris"),
screen.getByText("12 Rue de Paris, 75001 Paris"),
).toBeInTheDocument();
});

Expand Down Expand Up @@ -86,10 +86,10 @@ describe("CompanyInfoBanner", () => {
expect(screen.queryByText(/Effectif annuel moyen/)).not.toBeInTheDocument();
});

it("renders the 'Modifier les informations' button", () => {
it("renders the 'Modifier' button", () => {
render(<CompanyInfoBanner company={baseCompany} />);
expect(
screen.getByRole("button", { name: "Modifier les informations" }),
screen.getByRole("button", { name: "Modifier" }),
).toBeInTheDocument();
});
});
34 changes: 34 additions & 0 deletions packages/app/src/modules/my-space/formatAddress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Addresses from INSEE/Weez come fully uppercased. Render them in title case
// while keeping digits and short connector words (de, du, la, le, les…) lowercase.
const LOWERCASE_WORDS = new Set([
"de",
"du",
"des",
"la",
"le",
"les",
"et",
"en",
"aux",
"sur",
"sous",
"d",
"l",
]);

/**
* Converts a fully-uppercased INSEE/Weez address to title case.
* Short French connector words (de, du, la, le, …) are kept lowercase
* unless they appear as the first word of the address.
*
* @param address - The raw uppercase address string.
* @returns The address formatted in title case.
*/
export function formatAddress(address: string): string {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[IMPORTANT] Missing JSDoc on exported public function

Why it matters:

  • The coding guidelines require public functions to have concise docstrings explaining purpose and return values.
  • formatAddress is exported and non-trivial (locale-aware title-casing with a connector-word allowlist), so future readers benefit from a brief description.

Proposed addition:

Suggested change
export function formatAddress(address: string): string {
/**
* Converts a fully-uppercased INSEE/Weez address to title case.
* Short French connector words (de, du, la, le, ) are kept lowercase
* unless they are the first word of the address.
*
* @param address - The raw uppercase address string.
* @returns The address formatted in title case.
*/
export function formatAddress(address: string): string {

return address
.toLocaleLowerCase("fr-FR")
.replace(/\p{L}+/gu, (word, offset: number) => {
if (offset > 0 && LOWERCASE_WORDS.has(word)) return word;
return word[0]!.toLocaleUpperCase("fr-FR") + word.slice(1);
});
}
Comment on lines +27 to +34
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CRITICAL] offset is the character offset of the match, not the word index — first-word capitalisation is broken

Why it matters:

  • The offset parameter passed by String.prototype.replace is the character position of the match within the string, not a word counter.
  • offset > 0 is therefore false only when the very first character of the entire string is a letter. Any address that starts with a digit or whitespace (e.g. "12 RUE DE PARIS") will have offset > 0 for every word, including "rue", which is in LOWERCASE_WORDS — so "rue" will be left lowercase instead of being capitalised.
  • Concretely, formatAddress("12 RUE DE PARIS") currently returns "12 rue de paris" instead of "12 Rue de Paris".

Proposed fix: track whether a capitalised word has already been emitted using a closure variable.

Suggested change
export function formatAddress(address: string): string {
return address
.toLocaleLowerCase("fr-FR")
.replace(/\p{L}+/gu, (word, offset: number) => {
if (offset > 0 && LOWERCASE_WORDS.has(word)) return word;
return word[0]!.toLocaleUpperCase("fr-FR") + word.slice(1);
});
}
export function formatAddress(address: string): string {
let firstWordCapitalised = false;
return address
.toLocaleLowerCase("fr-FR")
.replace(/\p{L}+/gu, (word) => {
if (firstWordCapitalised && LOWERCASE_WORDS.has(word)) return word;
firstWordCapitalised = true;
return word[0]!.toLocaleUpperCase("fr-FR") + word.slice(1);
});
}

This correctly capitalises the first alphabetic word regardless of any leading digits/punctuation, and keeps connector words lowercase thereafter.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive: rue is not in LOWERCASE_WORDS (only connectors like de, du, la, le, les, et, en, aux, sur, sous, d, l). The existing test "12 RUE DE PARIS, 75001 PARIS""12 Rue de Paris, 75001 Paris" passes. The offset > 0 guard exists to capitalize a connector when it's the very first word (e.g. "LE MANS""Le Mans").

Loading