Skip to content

Commit

Permalink
CMS-622: Update CSV export options and functions (#87)
Browse files Browse the repository at this point in the history
  • Loading branch information
duncan-oxd authored Jan 21, 2025
1 parent 6c27239 commit ae2a5a1
Show file tree
Hide file tree
Showing 5 changed files with 147 additions and 59 deletions.
21 changes: 13 additions & 8 deletions backend/routes/api/export.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ router.get(
order: [["name", "ASC"]],
});

const dateTypes = DateType.findAll({
attributes: ["id", "name"],
order: [["name", "ASC"]],
});

const years = (
await Season.findAll({
attributes: [
Expand All @@ -44,6 +49,7 @@ router.get(
res.json({
years,
featureTypes: await featureTypes,
dateTypes: await dateTypes,
});
}),
);
Expand Down Expand Up @@ -74,6 +80,7 @@ router.get(
// Cast query param values as numbers
const operatingYear = +req.query.year;
const featureTypeIds = req.query.features?.map((id) => +id) ?? [];
const dateTypeIds = req.query.dateTypes?.map((id) => +id) ?? [];

// Update WHERE clause based on query parameters
const featuresWhere = {
Expand Down Expand Up @@ -152,6 +159,12 @@ router.get(
model: DateType,
as: "dateType",
attributes: ["id", "name"],
where: {
id: {
[Op.in]: dateTypeIds,
},
},
required: true,
},
],
},
Expand Down Expand Up @@ -196,16 +209,8 @@ router.get(
// Convert to CSV string
const csv = await writeToString(sorted, { headers: true });

// Build filename
const displayType =
exportType === "bcp-only" ? "BCP reservations only" : "All dates";
const dateTypes = "All types"; // @TODO: Make this dynamic when the date type selection is implemented
// (CMS-622: "Operating", "Reservations", "Winter fees", "All types")
const filename = `${operatingYear} season - ${displayType} - ${dateTypes}.csv`;

// Send CSV string as response
res.setHeader("Content-Type", "text/csv");
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
res.setHeader("Content-Length", Buffer.byteLength(csv));
res.send(csv);
}),
Expand Down
7 changes: 7 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"classnames": "^2.5.1",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"file-saver": "^2.0.5",
"lodash": "^4.17.21",
"oidc-client-ts": "^3.0.1",
"prop-types": "^15.8.1",
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/hooks/useApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,12 @@ export function useApiGet(endpoint, options = {}) {
const response = await axiosInstance.get(endpoint, config);

setData(response.data);

// Return the response (to use with instant=false)
return response.data;
} catch (err) {
setError(err);
throw err;
} finally {
setLoading(false);
}
Expand All @@ -62,7 +66,7 @@ export function useApiGet(endpoint, options = {}) {
useEffect(() => {
// Prevent sending multiple requests
if (instant && !requestSent.current) {
fetchData();
fetchData().catch((err) => console.error("fetchData error", err));
}
}, [fetchData, instant]);

Expand Down
171 changes: 121 additions & 50 deletions frontend/src/router/pages/ExportPage.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import classNames from "classnames";
import { saveAs } from "file-saver";
import { faCalendarCheck } from "@fa-kit/icons/classic/regular";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useApiGet } from "@/hooks/useApi";
import LoadingBar from "@/components/LoadingBar";
import getEnv from "@/config/getEnv";
import { useFlashMessage } from "@/hooks/useFlashMessage";
import FlashMessage from "@/components/FlashMessage";

Expand All @@ -22,54 +22,87 @@ function ExportPage() {
const { data: options, loading, error } = useApiGet("/export/options");
const [exportYear, setExportYear] = useState();
const [exportFeatures, setExportFeatures] = useState([]);
const exportTypes = [
{ value: "all", label: "All dates" },
{ value: "bcp-only", label: "BCP reservations only" },
];
const [exportDateTypes, setExportDateTypes] = useState([]);
const [exportType, setExportType] = useState("all");

const { generating, fetchData: fetchCsv } = useApiGet("/export/csv", {
instant: false,
params: {
type: exportType,
year: exportYear,
"features[]": exportFeatures,
"dateTypes[]": exportDateTypes,
},
});

// Set the initial values when options are loaded
useEffect(() => {
// Initially select the latest year from the data
if (options?.years) {
setExportYear(options.years.at(-1));
}
}, [options]);

// Initially select all feature types
// Selects every feature type's ID
function selectAllFeatures() {
if (options?.featureTypes?.length) {
setExportFeatures(options.featureTypes.map((feature) => feature.id));
}
}, [options]);
}

function onExportFeaturesChange(event) {
const { checked } = event.target;
const value = +event.target.value;
// Returns a function to handle checkbox group changes
function onCheckboxGroupChange(setter) {
// Adds or removes an value from the selection array
return function (event) {
const { checked } = event.target;
const value = +event.target.value;

setExportFeatures((prevFeatures) => {
if (checked) {
return [...prevFeatures, value];
}
setter((previousValues) => {
if (checked) {
return [...previousValues, value];
}

return prevFeatures.filter((feature) => feature !== value);
});
return previousValues.filter((feature) => feature !== value);
});
};
}

const exportTypes = [
{ value: "all", label: "All dates" },
{ value: "bcp-only", label: "BCP reservations only" },
];
const [exportType, setExportType] = useState("all");
// Fetches the CSV as plain text, and then saves it as a file.
async function getCsv() {
try {
const csvData = await fetchCsv();

function getExportUrl() {
const url = new URL(getEnv("VITE_API_BASE_URL"));
const params = new URLSearchParams();
// Build filename
const displayType =
exportType === "bcp-only" ? "BCP reservations only" : "All dates";

url.pathname += "/export/csv";
let dateTypes = "All types";

params.append("type", exportType);
params.append("year", exportYear);
exportFeatures.forEach((featureId) => {
params.append("features[]", featureId);
});
// If any date types are unselected, display a list
if (exportDateTypes.length < options.dateTypes.length) {
dateTypes = exportDateTypes
.map((id) => options.dateTypes.find((t) => t.id === id).name)
.join(", ");
}

const filename = `${exportYear} season - ${displayType} - ${dateTypes}.csv`;

// Convert CSV string to blob and save in the browser
const blob = new Blob([csvData], { type: "text/csv;charset=utf-8;" });

url.search = params.toString();
saveAs(blob, filename);

return url;
openFlashMessage(
"Export complete",
"Check your Downloads for the Excel document.",
);
} catch (csvError) {
console.error("Error generating CSV", csvError);
}
}

if (error) {
Expand All @@ -83,6 +116,9 @@ function ExportPage() {
</div>
);

const disableButton =
exportFeatures.length === 0 || exportDateTypes.length === 0 || generating;

return (
<div className="page export">
<FlashMessage
Expand Down Expand Up @@ -140,6 +176,25 @@ function ExportPage() {
</fieldset>
<fieldset className="section-spaced">
<legend className="append-required">Park features</legend>

<div className="mb-2">
<button
onClick={selectAllFeatures}
type="button"
className="btn btn-text-primary"
>
Select all
</button>
|
<button
onClick={() => setExportFeatures([])}
type="button"
className="btn btn-text-primary"
>
Clear all
</button>
</div>

{options.featureTypes.map((feature) => (
<div className="form-check" key={feature.id}>
<input
Expand All @@ -149,7 +204,7 @@ function ExportPage() {
id={`features-${feature.id}`}
value={feature.id}
checked={exportFeatures.includes(feature.id)}
onChange={onExportFeaturesChange}
onChange={onCheckboxGroupChange(setExportFeatures)}
/>
<label
className="form-check-label"
Expand All @@ -160,30 +215,46 @@ function ExportPage() {
</div>
))}
</fieldset>
<fieldset>
<a
href={getExportUrl()}
target="_blank"
rel="noopener"
className={classNames({
btn: true,
"btn-primary": true,
disabled: exportFeatures.length === 0,
<fieldset className="section-spaced">
<legend className="append-required">Type of date</legend>

{options.dateTypes.map((dateType) => (
<div className="form-check" key={dateType.id}>
<input
className="form-check-input"
type="checkbox"
name="features"
id={`date-types-${dateType.id}`}
value={dateType.id}
checked={exportDateTypes.includes(dateType.id)}
onChange={onCheckboxGroupChange(setExportDateTypes)}
/>
<label
className="form-check-label"
htmlFor={`date-types-${dateType.id}`}
>
{dateType.name}
</label>
</div>
))}
</fieldset>
<fieldset className="d-flex">
<button
role="button"
className={classNames("btn btn-primary", {
disabled: disableButton,
})}
onClick={(ev) => {
if (exportFeatures.length === 0) {
ev.preventDefault();
} else {
// Show success flash message
openFlashMessage(
"Export complete",
"Check your Downloads for the Excel document.",
);
}
}}
onClick={getCsv}
>
Export report
</a>
</button>

{generating && (
<span
className="spinner-border text-primary align-self-center ms-2"
aria-hidden="true"
></span>
)}
</fieldset>
</div>
</div>
Expand Down

0 comments on commit ae2a5a1

Please sign in to comment.