From 2218fcf4c57fb6f926e22506c4459e14df2955f8 Mon Sep 17 00:00:00 2001 From: Kyle Deal Date: Tue, 22 Oct 2024 17:12:46 +0000 Subject: [PATCH 1/2] Extract some common components from update subscription page I am making a create subscription page and it will use these components --- frontend/components/DisplayErrors.js | 31 ++ frontend/components/EditSubscription.js | 501 ++++++++++++++++++++++ frontend/containers/Subscription.js | 535 +----------------------- frontend/lib/subscription.js | 28 ++ 4 files changed, 569 insertions(+), 526 deletions(-) create mode 100644 frontend/components/DisplayErrors.js create mode 100644 frontend/components/EditSubscription.js create mode 100644 frontend/lib/subscription.js diff --git a/frontend/components/DisplayErrors.js b/frontend/components/DisplayErrors.js new file mode 100644 index 0000000..bb630de --- /dev/null +++ b/frontend/components/DisplayErrors.js @@ -0,0 +1,31 @@ +import React from "react"; +import PropTypes from "prop-types"; + +const ErrorMessageShape = PropTypes.shape({ + loc: PropTypes.arrayOf( + PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + ), + msg: PropTypes.string.isRequired, +}); + +function DisplayErrorMessage({ errors }) { + return ( +
+ Error saving subscription. + +
+ ); +} + +DisplayErrorMessage.propTypes = { + errors: PropTypes.arrayOf(ErrorMessageShape).isRequired, +}; + +export default DisplayErrorMessage; diff --git a/frontend/components/EditSubscription.js b/frontend/components/EditSubscription.js new file mode 100644 index 0000000..7ff5ea2 --- /dev/null +++ b/frontend/components/EditSubscription.js @@ -0,0 +1,501 @@ +import React from "react"; +import PropTypes from "prop-types"; + +const DAYS_OF_THE_WEEK = [ + { label: "Sunday", value: "sunday" }, + { label: "Monday", value: "monday" }, + { label: "Tuesday", value: "tuesday" }, + { label: "Wednesday", value: "wednesday" }, + { label: "Thursday", value: "thursday" }, + { label: "Friday", value: "friday" }, + { label: "Saturday", value: "saturday" }, +]; + +const RULE_LOGIC_OPTIONS = [ + { label: "all rules", value: "all" }, + { label: "at least one rule", value: "any" }, +]; + +const RuleShape = PropTypes.shape({ + field: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + uuid: PropTypes.string.isRequired, +}); +const TimeSlotShape = PropTypes.shape({ + day: PropTypes.string.isRequired, + hour: PropTypes.number.isRequired, + minute: PropTypes.number.isRequired, + uuid: PropTypes.string.isRequired, +}); + +const SubscriptionShape = PropTypes.shape({ + location: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + office: PropTypes.string.isRequired, + rule_logic: PropTypes.string.isRequired, + rules: PropTypes.arrayOf(RuleShape).isRequired, + size: PropTypes.number.isRequired, + time_slots: PropTypes.arrayOf(TimeSlotShape).isRequired, + timezone: PropTypes.string.isRequired, + default_auto_opt_in: PropTypes.bool.isRequired, +}); + +function StringField({ field, label, value, inputClassName, updateField }) { + return ( +
+ + updateField(field, e.target.value)} + required + /> +
+ ); +} + +StringField.propTypes = { + field: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + inputClassName: PropTypes.string, + updateField: PropTypes.func.isRequired, +}; + +StringField.defaultProps = { + inputClassName: "", +}; + +function DefaultAutoOptInField({ field, label, value, updateField }) { + return ( +
+ + +
+ ); +} + +DefaultAutoOptInField.propTypes = { + field: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + value: PropTypes.bool.isRequired, + updateField: PropTypes.func.isRequired, +}; + +function NumberField({ field, label, value, updateField, min }) { + return ( +
+ + updateField(field, e.target.value)} + required + /> +
+ ); +} + +NumberField.propTypes = { + field: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + value: PropTypes.number.isRequired, + updateField: PropTypes.func.isRequired, + min: PropTypes.number, +}; +NumberField.defaultProps = { + min: null, +}; + +function RuleField({ rule, updateRule, removeRule }) { + const ruleFieldId = `rule-${rule.uuid}-field`; + const ruleValueId = `rule-${rule.uuid}-value`; + // I can't figure out how to make this pass and they do have an related control + /* eslint-disable jsx-a11y/label-has-associated-control */ + return ( +
+ +
+ updateRule(rule.uuid, "field", e.target.value)} + required + /> +
+ +
+ updateRule(rule.uuid, "value", e.target.value)} + required + /> +
+
+ +
+
+ ); + /* eslint-enable jsx-a11y/label-has-associated-control */ +} + +RuleField.propTypes = { + rule: RuleShape.isRequired, + updateRule: PropTypes.func.isRequired, + removeRule: PropTypes.func.isRequired, +}; + +// I can't figure out how to make this pass and they do have an related control +/* eslint-disable jsx-a11y/label-has-associated-control */ +function RulesField({ rules, ruleLogic, updateField }) { + const addRule = () => + updateField("rules", (existRules) => { + const newRules = existRules.slice(); + newRules.push({ uuid: crypto.randomUUID(), field: "", value: "" }); + return newRules; + }); + + const updateRule = (uuid, field, value) => { + updateField("rules", (existRules) => + existRules.map((rule) => { + if (rule.uuid !== uuid) { + return rule; + } + const newRule = { ...rule }; + newRule[field] = value; + return newRule; + }), + ); + }; + const removeRule = (uuid) => { + updateField("rules", (existRules) => + existRules.filter((rule) => rule.uuid !== uuid), + ); + }; + + return ( +
+

Rules

+
+ +
+ +
+
+
+ {rules.map((rule) => ( + + ))} + +
+
+ ); +} +/* eslint-enable jsx-a11y/label-has-associated-control */ + +RulesField.propTypes = { + rules: PropTypes.arrayOf(RuleShape).isRequired, + ruleLogic: PropTypes.string, + updateField: PropTypes.func.isRequired, +}; +RulesField.defaultProps = { + ruleLogic: null, +}; + +const formatTime = (hour, minute) => { + const paddedHour = hour.toString().padStart(2, "0"); + const paddedMinute = minute.toString().padStart(2, "0"); + return `${paddedHour}:${paddedMinute}`; +}; + +function TimeSlotField({ timeSlot, updateTimeSlot, removeTimeSlot }) { + const dayId = `timeSlot-${timeSlot.uuid}-day`; + const timeId = `timeSlot-${timeSlot.uuid}-time`; + const [time, setTime] = React.useState( + formatTime(timeSlot.hour, timeSlot.minute), + ); + const [timeError, setTimeError] = React.useState(null); + + React.useEffect(() => { + const parts = time.split(":"); + // Default to invalid value, so update doesn't work if there is a bad string + let hour = -1; + let minute = -1; + if (parts.length === 2) { + const [strHour, strMinute] = parts; + const parsedHour = parseInt(strHour, 10); + const parsedMinute = parseInt(strMinute, 10); + if (Number.isNaN(parsedHour) || Number.isNaN(parsedMinute)) { + setTimeError("Couldn't parse hour and/or minute as a number"); + } else { + setTimeError(null); + hour = parsedHour; + minute = parsedMinute; + } + } else { + setTimeError("Invalid Time format. Must be HH:MM"); + } + + updateTimeSlot(timeSlot.uuid, "hour", hour); + updateTimeSlot(timeSlot.uuid, "minute", minute); + }, [time]); + // I can't figure out how to make this pass and they do have an related control + /* eslint-disable jsx-a11y/label-has-associated-control */ + return ( +
+ +
+ +
+ +
+ setTime(e.target.value)} + required + /> +
+ {timeError && ( +
+ {timeError} +
+ )} +
+ +
+
+ ); + /* eslint-enable jsx-a11y/label-has-associated-control */ +} + +TimeSlotField.propTypes = { + timeSlot: TimeSlotShape.isRequired, + updateTimeSlot: PropTypes.func.isRequired, + removeTimeSlot: PropTypes.func.isRequired, +}; + +function TimeSlotsField({ timeSlots, timezone, updateField }) { + const addTimeSlot = () => + updateField("time_slots", (existTimeSlots) => { + const newTimeSlots = existTimeSlots.slice(); + newTimeSlots.push({ + uuid: crypto.randomUUID(), + day: "monday", + hour: 0, + minute: 0, + }); + return newTimeSlots; + }); + const updateTimeSlot = (uuid, field, value) => { + updateField("time_slots", (existTimeSlots) => + existTimeSlots.map((timeSlot) => { + if (timeSlot.uuid !== uuid) { + return timeSlot; + } + const newtimeSlot = { ...timeSlot }; + newtimeSlot[field] = value; + return newtimeSlot; + }), + ); + }; + const removeTimeSlot = (uuid) => { + updateField("time_slots", (existTimeSlots) => + existTimeSlots.filter((timeSlot) => timeSlot.uuid !== uuid), + ); + }; + + return ( +
+

Meeting Times

+ +
+ {timeSlots.map((timeSlot) => ( + + ))} + +
+
+ ); +} + +TimeSlotsField.propTypes = { + timeSlots: PropTypes.arrayOf(TimeSlotShape).isRequired, + timezone: PropTypes.string.isRequired, + updateField: PropTypes.func.isRequired, +}; + +function EditSubscription({ subscription, setSubscription }) { + const updateField = (field, newValue) => { + setSubscription((oldConfig) => { + const newConfig = { ...oldConfig }; + if (newValue instanceof Function) { + newConfig[field] = newValue(newConfig[field]); + } else { + newConfig[field] = newValue; + } + return newConfig; + }); + }; + + return ( +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+ ); +} + +EditSubscription.propTypes = { + subscription: SubscriptionShape.isRequired, + setSubscription: PropTypes.func.isRequired, +}; + +export default EditSubscription; diff --git a/frontend/containers/Subscription.js b/frontend/containers/Subscription.js index bf2d368..f22ee48 100644 --- a/frontend/containers/Subscription.js +++ b/frontend/containers/Subscription.js @@ -1,462 +1,15 @@ import axios from "axios"; import React from "react"; -import PropTypes from "prop-types"; -const DAYS_OF_THE_WEEK = [ - { label: "Sunday", value: "sunday" }, - { label: "Monday", value: "monday" }, - { label: "Tuesday", value: "tuesday" }, - { label: "Wednesday", value: "wednesday" }, - { label: "Thursday", value: "thursday" }, - { label: "Friday", value: "friday" }, - { label: "Saturday", value: "saturday" }, -]; - -const RULE_LOGIC_OPTIONS = [ - { label: "all rules", value: "all" }, - { label: "at least one rule", value: "any" }, -]; - -const RuleShape = PropTypes.shape({ - field: PropTypes.string.isRequired, - value: PropTypes.string.isRequired, - uuid: PropTypes.string.isRequired, -}); -const TimeSlotShape = PropTypes.shape({ - day: PropTypes.string.isRequired, - hour: PropTypes.number.isRequired, - minute: PropTypes.number.isRequired, - uuid: PropTypes.string.isRequired, -}); +import { addUUID, prepareSubscriptionPayload } from "../lib/subscription"; +import EditSubscription from "../components/EditSubscription"; +import DisplayErrorMessage from "../components/DisplayErrors"; const getSubscriptionId = () => { const path = window.location.pathname.split("/"); return path[path.length - 1]; }; -function StringField({ field, label, value, inputClassName, updateField }) { - return ( -
- - updateField(field, e.target.value)} - required - /> -
- ); -} - -StringField.propTypes = { - field: PropTypes.string.isRequired, - label: PropTypes.string.isRequired, - value: PropTypes.string.isRequired, - inputClassName: PropTypes.string, - updateField: PropTypes.func.isRequired, -}; - -StringField.defaultProps = { - inputClassName: "", -}; - -function DefaultAutoOptInField({ field, label, value, updateField }) { - return ( -
- - -
- ); -} - -DefaultAutoOptInField.propTypes = { - field: PropTypes.string.isRequired, - label: PropTypes.string.isRequired, - value: PropTypes.bool.isRequired, - updateField: PropTypes.func.isRequired, -}; - -function NumberField({ field, label, value, updateField, min }) { - return ( -
- - updateField(field, e.target.value)} - required - /> -
- ); -} - -NumberField.propTypes = { - field: PropTypes.string.isRequired, - label: PropTypes.string.isRequired, - value: PropTypes.number.isRequired, - updateField: PropTypes.func.isRequired, - min: PropTypes.number, -}; -NumberField.defaultProps = { - min: null, -}; - -function RuleField({ rule, updateRule, removeRule }) { - const ruleFieldId = `rule-${rule.uuid}-field`; - const ruleValueId = `rule-${rule.uuid}-value`; - // I can't figure out how to make this pass and they do have an related control - /* eslint-disable jsx-a11y/label-has-associated-control */ - return ( -
- -
- updateRule(rule.uuid, "field", e.target.value)} - required - /> -
- -
- updateRule(rule.uuid, "value", e.target.value)} - required - /> -
-
- -
-
- ); - /* eslint-enable jsx-a11y/label-has-associated-control */ -} - -RuleField.propTypes = { - rule: RuleShape.isRequired, - updateRule: PropTypes.func.isRequired, - removeRule: PropTypes.func.isRequired, -}; - -// I can't figure out how to make this pass and they do have an related control -/* eslint-disable jsx-a11y/label-has-associated-control */ -function RulesField({ rules, ruleLogic, updateField }) { - const addRule = () => - updateField("rules", (existRules) => { - const newRules = existRules.slice(); - newRules.push({ uuid: crypto.randomUUID(), field: "", value: "" }); - return newRules; - }); - - const updateRule = (uuid, field, value) => { - updateField("rules", (existRules) => - existRules.map((rule) => { - if (rule.uuid !== uuid) { - return rule; - } - const newRule = { ...rule }; - newRule[field] = value; - return newRule; - }), - ); - }; - const removeRule = (uuid) => { - updateField("rules", (existRules) => - existRules.filter((rule) => rule.uuid !== uuid), - ); - }; - - return ( -
-

Rules

-
- -
- -
-
-
- {rules.map((rule) => ( - - ))} - -
-
- ); -} -/* eslint-enable jsx-a11y/label-has-associated-control */ - -RulesField.propTypes = { - rules: PropTypes.arrayOf(RuleShape).isRequired, - ruleLogic: PropTypes.string, - updateField: PropTypes.func.isRequired, -}; -RulesField.defaultProps = { - ruleLogic: null, -}; - -const formatTime = (hour, minute) => { - const paddedHour = hour.toString().padStart(2, "0"); - const paddedMinute = minute.toString().padStart(2, "0"); - return `${paddedHour}:${paddedMinute}`; -}; - -function TimeSlotField({ timeSlot, updateTimeSlot, removeTimeSlot }) { - const dayId = `timeSlot-${timeSlot.uuid}-day`; - const timeId = `timeSlot-${timeSlot.uuid}-time`; - const [time, setTime] = React.useState( - formatTime(timeSlot.hour, timeSlot.minute), - ); - const [timeError, setTimeError] = React.useState(null); - - React.useEffect(() => { - const parts = time.split(":"); - // Default to invalid value, so update doesn't work if there is a bad string - let hour = -1; - let minute = -1; - if (parts.length === 2) { - const [strHour, strMinute] = parts; - const parsedHour = parseInt(strHour, 10); - const parsedMinute = parseInt(strMinute, 10); - if (Number.isNaN(parsedHour) || Number.isNaN(parsedMinute)) { - setTimeError("Couldn't parse hour and/or minute as a number"); - } else { - setTimeError(null); - hour = parsedHour; - minute = parsedMinute; - } - } else { - setTimeError("Invalid Time format. Must be HH:MM"); - } - - updateTimeSlot(timeSlot.uuid, "hour", hour); - updateTimeSlot(timeSlot.uuid, "minute", minute); - }, [time]); - // I can't figure out how to make this pass and they do have an related control - /* eslint-disable jsx-a11y/label-has-associated-control */ - return ( -
- -
- -
- -
- setTime(e.target.value)} - required - /> -
- {timeError && ( -
- {timeError} -
- )} -
- -
-
- ); - /* eslint-enable jsx-a11y/label-has-associated-control */ -} - -TimeSlotField.propTypes = { - timeSlot: TimeSlotShape.isRequired, - updateTimeSlot: PropTypes.func.isRequired, - removeTimeSlot: PropTypes.func.isRequired, -}; - -function TimeSlotsField({ timeSlots, timezone, updateField }) { - const addTimeSlot = () => - updateField("time_slots", (existTimeSlots) => { - const newTimeSlots = existTimeSlots.slice(); - newTimeSlots.push({ - uuid: crypto.randomUUID(), - day: "monday", - hour: 0, - minute: 0, - }); - return newTimeSlots; - }); - const updateTimeSlot = (uuid, field, value) => { - updateField("time_slots", (existTimeSlots) => - existTimeSlots.map((timeSlot) => { - if (timeSlot.uuid !== uuid) { - return timeSlot; - } - const newtimeSlot = { ...timeSlot }; - newtimeSlot[field] = value; - return newtimeSlot; - }), - ); - }; - const removeTimeSlot = (uuid) => { - updateField("time_slots", (existTimeSlots) => - existTimeSlots.filter((timeSlot) => timeSlot.uuid !== uuid), - ); - }; - - return ( -
-

Meeting Times

- -
- {timeSlots.map((timeSlot) => ( - - ))} - -
-
- ); -} - -TimeSlotsField.propTypes = { - timeSlots: PropTypes.arrayOf(TimeSlotShape).isRequired, - timezone: PropTypes.string.isRequired, - updateField: PropTypes.func.isRequired, -}; - -function ErrorMessage({ errors }) { - return ( -
- Error updating subscription. - -
- ); -} - -ErrorMessage.propTypes = { - errors: PropTypes.arrayOf( - PropTypes.shape({ - loc: PropTypes.arrayOf( - PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - ), - msg: PropTypes.string.isRequired, - }), - ).isRequired, -}; - -const addUUID = (array) => - array.map((item) => { - const newItem = { ...item }; - newItem.uuid = crypto.randomUUID(); - return newItem; - }); - -const removeUUID = (array) => - array.map((item) => { - const { uuid, ...remainingFields } = item; - return remainingFields; - }); - function Subscription() { const [subscription, setSubscription] = React.useState(null); const [saving, setSaving] = React.useState(false); @@ -475,33 +28,10 @@ function Subscription() { return ""; } - const updateField = (field, newValue) => { - setSubscription((oldConfig) => { - const newConfig = { ...oldConfig }; - if (newValue instanceof Function) { - newConfig[field] = newValue(newConfig[field]); - } else { - newConfig[field] = newValue; - } - return newConfig; - }); - }; - const updateSubscription = () => { setSaving(true); - const { id, ...payloadSubscription } = subscription; - payloadSubscription.rules = removeUUID(payloadSubscription.rules); - payloadSubscription.time_slots = removeUUID(payloadSubscription.time_slots); - // we don't require setting the rule logic if there is only rule because all vs any doesn't - // change anything if there is one rule. On the backend we require that we set, so default - // to all in that case - if ( - payloadSubscription.rules.length === 1 && - payloadSubscription.rule_logic == null - ) { - payloadSubscription.rule_logic = "all"; - } - + const { id, ...subscriptionData } = subscription; + const payloadSubscription = prepareSubscriptionPayload(subscriptionData); setSaving(false); axios .put(`/v1/subscriptions/${id}`, payloadSubscription) @@ -522,57 +52,10 @@ function Subscription() { return (
- {errors && } - -
-
- -
-
- -
-
- -
-
- -
-
- - } + +
+ ); +} + +export default CreateSubscription; diff --git a/frontend/containers/SubscriptionsList.js b/frontend/containers/SubscriptionsList.js index 8d5e9ed..e67bdd5 100644 --- a/frontend/containers/SubscriptionsList.js +++ b/frontend/containers/SubscriptionsList.js @@ -28,6 +28,9 @@ function SubscriptionList() { return (

Subscriptions

+ + New Subscription + diff --git a/frontend/index.js b/frontend/index.js index c29212e..08dbb9b 100644 --- a/frontend/index.js +++ b/frontend/index.js @@ -10,6 +10,7 @@ import MeetingRequest from "./containers/MeetingRequest"; import User from "./containers/User"; import SubscriptionsList from "./containers/SubscriptionsList"; import Subscription from "./containers/Subscription"; +import CreateSubscription from "./containers/CreateSubscription"; import Subscribe from "./containers/Subscribe"; const router = createBrowserRouter([ @@ -33,6 +34,10 @@ const router = createBrowserRouter([ path: "/subscribe/:id", element: , }, + { + path: "/admin/subscriptions/create", + element: , + }, { path: "/admin/subscriptions/:id", element: , diff --git a/frontend/webapp.js b/frontend/webapp.js index a5bd6cc..4bdacc7 100644 --- a/frontend/webapp.js +++ b/frontend/webapp.js @@ -69,6 +69,10 @@ app.get("/admin/subscriptions", (req, res) => { res.sendFile(`${__dirname}/index.html`); }); +app.get("/admin/subscriptions/create", (req, res) => { + res.sendFile(`${__dirname}/index.html`); +}); + app.get("/admin/subscriptions/:id", (req, res) => { res.sendFile(`${__dirname}/index.html`); });