Skip to content

Commit c53e2c3

Browse files
feat(shared): CompanySizeFilter + COMPANY_SIZE_RANGES (#3283)
1 parent c19e5ea commit c53e2c3

7 files changed

Lines changed: 209 additions & 3 deletions

File tree

packages/app/src/modules/domain/__tests__/companySize.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, expect, it } from "vitest";
22

3-
import { classifyCompanySize, isCseRequired } from "../shared/companySize";
3+
import {
4+
COMPANY_SIZE_RANGES,
5+
classifyCompanySize,
6+
isCseRequired,
7+
} from "../shared/companySize";
48

59
describe("classifyCompanySize", () => {
610
it("returns voluntary for workforce below 50", () => {
@@ -31,3 +35,46 @@ describe("isCseRequired", () => {
3135
expect(isCseRequired(500)).toBe(true);
3236
});
3337
});
38+
39+
describe("COMPANY_SIZE_RANGES", () => {
40+
it("exposes the five buckets in UI order", () => {
41+
expect(Object.keys(COMPANY_SIZE_RANGES)).toEqual([
42+
"<50",
43+
"50-99",
44+
"100-149",
45+
"150-249",
46+
"250+",
47+
]);
48+
});
49+
50+
it("uses contiguous, non-overlapping bounds", () => {
51+
expect(COMPANY_SIZE_RANGES["<50"]).toEqual({
52+
min: 0,
53+
max: 49,
54+
label: "Moins de 50 salariés",
55+
});
56+
expect(COMPANY_SIZE_RANGES["50-99"]).toEqual({
57+
min: 50,
58+
max: 99,
59+
label: "50 à 99 salariés",
60+
});
61+
expect(COMPANY_SIZE_RANGES["100-149"]).toEqual({
62+
min: 100,
63+
max: 149,
64+
label: "100 à 149 salariés",
65+
});
66+
expect(COMPANY_SIZE_RANGES["150-249"]).toEqual({
67+
min: 150,
68+
max: 249,
69+
label: "150 à 249 salariés",
70+
});
71+
});
72+
73+
it("leaves the top bucket open-ended", () => {
74+
expect(COMPANY_SIZE_RANGES["250+"]).toEqual({
75+
min: 250,
76+
max: null,
77+
label: "250 salariés et plus",
78+
});
79+
});
80+
});

packages/app/src/modules/domain/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ export {
1111
shouldRedirectSubmittedToRecap,
1212
} from "./shared/campaign";
1313
// Company size
14-
export { classifyCompanySize, isCseRequired } from "./shared/companySize";
14+
export {
15+
COMPANY_SIZE_RANGES,
16+
classifyCompanySize,
17+
isCseRequired,
18+
} from "./shared/companySize";
1519
// Constants
1620
export {
1721
COMPANY_SIZE_ANNUAL_MIN,
@@ -65,6 +69,7 @@ export { extractSiren, formatSiren, parseSiren } from "./shared/siren";
6569
export type {
6670
CampaignDeadlines,
6771
CompanySize,
72+
CompanySizeRange,
6873
DeclarationStatus,
6974
DeclarationType,
7075
GapLevel,

packages/app/src/modules/domain/shared/companySize.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { CompanySize } from "../types";
1+
import type { CompanySize, CompanySizeRange } from "../types";
22
import {
33
COMPANY_SIZE_ANNUAL_MIN,
44
COMPANY_SIZE_VOLUNTARY_MAX,
@@ -15,3 +15,19 @@ export function classifyCompanySize(workforce: number): CompanySize {
1515
export function isCseRequired(workforce: number): boolean {
1616
return workforce >= COMPANY_SIZE_ANNUAL_MIN;
1717
}
18+
19+
/**
20+
* Workforce range buckets shared by admin and public statistics filters.
21+
* `max: null` means the bucket is open-ended (no upper bound).
22+
* Insertion order matches the expected UI order.
23+
*/
24+
export const COMPANY_SIZE_RANGES: Record<
25+
CompanySizeRange,
26+
{ min: number; max: number | null; label: string }
27+
> = {
28+
"<50": { min: 0, max: 49, label: "Moins de 50 salariés" },
29+
"50-99": { min: 50, max: 99, label: "50 à 99 salariés" },
30+
"100-149": { min: 100, max: 149, label: "100 à 149 salariés" },
31+
"150-249": { min: 150, max: 249, label: "150 à 249 salariés" },
32+
"250+": { min: 250, max: null, label: "250 salariés et plus" },
33+
};

packages/app/src/modules/domain/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ export type DeclarationType = "remuneration" | "representation";
1010
/** Company size classification for declaration obligations. */
1111
export type CompanySize = "voluntary" | "triennial" | "annual";
1212

13+
/** Workforce range buckets used by admin/public statistics filters. */
14+
export type CompanySizeRange = "<50" | "50-99" | "100-149" | "150-249" | "250+";
15+
1316
/** Configurable campaign deadlines. */
1417
export type CampaignDeadlines = {
1518
gipPublicationDate: Date | null;
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"use client";
2+
3+
import type { ChangeEvent } from "react";
4+
import { COMPANY_SIZE_RANGES, type CompanySizeRange } from "~/modules/domain";
5+
6+
type Props = {
7+
value: CompanySizeRange | undefined;
8+
onChange: (value: CompanySizeRange | undefined) => void;
9+
id?: string;
10+
label?: string;
11+
};
12+
13+
const ALL_SIZES_LABEL = "Toutes tailles";
14+
const RANGE_KEYS = Object.keys(COMPANY_SIZE_RANGES) as CompanySizeRange[];
15+
16+
export function CompanySizeFilter({
17+
value,
18+
onChange,
19+
id = "company-size-filter",
20+
label = "Filtrer par effectif",
21+
}: Props) {
22+
const handleChange = (event: ChangeEvent<HTMLSelectElement>) => {
23+
const next = event.target.value;
24+
onChange(next === "" ? undefined : (next as CompanySizeRange));
25+
};
26+
27+
return (
28+
<div className="fr-select-group">
29+
<label className="fr-label" htmlFor={id}>
30+
{label}
31+
</label>
32+
<select
33+
className="fr-select"
34+
id={id}
35+
name="sizeRange"
36+
onChange={handleChange}
37+
value={value ?? ""}
38+
>
39+
<option value="">{ALL_SIZES_LABEL}</option>
40+
{RANGE_KEYS.map((range) => (
41+
<option key={range} value={range}>
42+
{COMPANY_SIZE_RANGES[range].label}
43+
</option>
44+
))}
45+
</select>
46+
</div>
47+
);
48+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { userEvent } from "@testing-library/user-event";
3+
import { describe, expect, it, vi } from "vitest";
4+
5+
import { CompanySizeFilter } from "../CompanySizeFilter";
6+
7+
describe("CompanySizeFilter", () => {
8+
it("renders the six options in order", () => {
9+
render(<CompanySizeFilter onChange={vi.fn()} value={undefined} />);
10+
11+
const select = screen.getByRole("combobox", {
12+
name: "Filtrer par effectif",
13+
});
14+
const optionTexts = Array.from(select.querySelectorAll("option")).map(
15+
(option) => option.textContent,
16+
);
17+
expect(optionTexts).toEqual([
18+
"Toutes tailles",
19+
"Moins de 50 salariés",
20+
"50 à 99 salariés",
21+
"100 à 149 salariés",
22+
"150 à 249 salariés",
23+
"250 salariés et plus",
24+
]);
25+
});
26+
27+
it("reflects the controlled value", () => {
28+
render(<CompanySizeFilter onChange={vi.fn()} value="100-149" />);
29+
30+
const select = screen.getByRole<HTMLSelectElement>("combobox", {
31+
name: "Filtrer par effectif",
32+
});
33+
expect(select.value).toBe("100-149");
34+
});
35+
36+
it("calls onChange with the selected range", async () => {
37+
const onChange = vi.fn();
38+
const user = userEvent.setup();
39+
render(<CompanySizeFilter onChange={onChange} value={undefined} />);
40+
41+
await user.selectOptions(
42+
screen.getByRole("combobox", { name: "Filtrer par effectif" }),
43+
"50-99",
44+
);
45+
46+
expect(onChange).toHaveBeenCalledWith("50-99");
47+
});
48+
49+
it("calls onChange with undefined when 'Toutes tailles' is selected", async () => {
50+
const onChange = vi.fn();
51+
const user = userEvent.setup();
52+
render(<CompanySizeFilter onChange={onChange} value="250+" />);
53+
54+
await user.selectOptions(
55+
screen.getByRole("combobox", { name: "Filtrer par effectif" }),
56+
"Toutes tailles",
57+
);
58+
59+
expect(onChange).toHaveBeenCalledWith(undefined);
60+
});
61+
62+
it("links the label to the select via htmlFor/id", () => {
63+
render(
64+
<CompanySizeFilter id="effectif" onChange={vi.fn()} value={undefined} />,
65+
);
66+
67+
const select = screen.getByRole("combobox", {
68+
name: "Filtrer par effectif",
69+
});
70+
expect(select).toHaveAttribute("id", "effectif");
71+
});
72+
73+
it("accepts a custom label", () => {
74+
render(
75+
<CompanySizeFilter
76+
label="Tranche d'effectif"
77+
onChange={vi.fn()}
78+
value={undefined}
79+
/>,
80+
);
81+
82+
expect(
83+
screen.getByRole("combobox", { name: "Tranche d'effectif" }),
84+
).toBeInTheDocument();
85+
});
86+
});

packages/app/src/modules/shared/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { CompanySizeFilter } from "./CompanySizeFilter";
12
export { FileUpload } from "./FileUpload";
23
export { getDsfrModal } from "./getDsfrModal";
34
export { parseSiren } from "./parseSiren";

0 commit comments

Comments
 (0)