diff --git a/frontend/src/Components/InputTime/InputTime.module.scss b/frontend/src/Components/InputTime/InputTime.module.scss new file mode 100644 index 000000000..479e39ef9 --- /dev/null +++ b/frontend/src/Components/InputTime/InputTime.module.scss @@ -0,0 +1,42 @@ +@import 'src/constants'; + +@import 'src/mixins'; + +.inputTime { + display: flex; + align-items: left; +} + +.number { + width: 2em; + height: 1.5em; + box-sizing: border-box; + padding: auto 5px; + text-align: center; + border: 2px solid $grey-4; + @include theme-dark { + border-color: $grey-2; + } +} + +/* Chrome, Safari, Edge, Opera */ +.number::-webkit-outer-spin-button, +.number::-webkit-inner-spin-button { + appearance: none; +} + +/* Firefox */ +.number[type='number'] { + -moz-appearance: textfield; +} + +.inputTime, +.number { + border-radius: 8px; + font-size: 1em; +} + +.number:focus { + outline: 3px hidden var(--active); + box-shadow: 0 0 2px 2px #999999; +} diff --git a/frontend/src/Components/InputTime/InputTime.stories.tsx b/frontend/src/Components/InputTime/InputTime.stories.tsx new file mode 100644 index 000000000..ef84ff154 --- /dev/null +++ b/frontend/src/Components/InputTime/InputTime.stories.tsx @@ -0,0 +1,14 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { InputTime } from './InputTime'; + +export default { + title: 'Components/InputTime', + component: InputTime, +} as ComponentMeta; + +const Template: ComponentStory = function (args) { + return Option; +}; + +export const Basic = Template.bind({}); +Basic.args = {}; diff --git a/frontend/src/Components/InputTime/InputTime.tsx b/frontend/src/Components/InputTime/InputTime.tsx new file mode 100644 index 000000000..6f9f1ed58 --- /dev/null +++ b/frontend/src/Components/InputTime/InputTime.tsx @@ -0,0 +1,79 @@ +import { ChangeEvent, useEffect, useState } from 'react'; +import styles from './InputTime.module.scss'; + +type InputTimeProps = { + className?: string; + disabled?: boolean; + onChange?: (value: string) => void; + onBlur?: (value: string) => void; + value?: string; +}; + +export function InputTime({ onChange, onBlur, value }: InputTimeProps) { + const [hour, setHour] = useState(''); + const [minute, setMinute] = useState(''); + + useEffect(() => { + if (value) { + const [parsedHour, parsedMinute] = value.split(':'); + setHour(parsedHour); + setMinute(parsedMinute); + } + }, [value]); + + useEffect(() => { + const formattedTime = `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`; + onChange?.(formattedTime); + }, [hour, minute, onChange]); + + function handleChange(e: ChangeEvent) { + const inputName = e.target.getAttribute('name'); + let numericValue = e.target.value.replace(/[^0-9]/g, ''); + const parsedValue = parseInt(numericValue, 10); + if (inputName === 'hour') { + numericValue = parsedValue > 23 ? '23' : e.target.value; + // Regex for 00-23, allowing for values without 0 padding + if (/^(2[0-3]|[0-1]?[0-9])$/.test(numericValue) || numericValue.length === 0) { + setHour(numericValue); + onChange?.(numericValue.padStart(2, '0') + ':' + minute.padStart(2, '0')); + } + } else if (inputName === 'minute') { + numericValue = parsedValue > 59 ? '59' : e.target.value; + // Regex for 00-59, allowing for values without 0 padding + if (/^([0-5]?[0-9])$/.test(numericValue) || numericValue.length === 0) { + setMinute(numericValue); + onChange?.(hour.padStart(2, '0') + ':' + numericValue.padStart(2, '0')); + } + } + } + + function handleBlur() { + const formattedTime = `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`; + onBlur?.(formattedTime); + } + + return ( +
+
+ +

:

+ +
+
+
+ ); +} diff --git a/frontend/src/Components/InputTime/index.ts b/frontend/src/Components/InputTime/index.ts new file mode 100644 index 000000000..3718a0f70 --- /dev/null +++ b/frontend/src/Components/InputTime/index.ts @@ -0,0 +1 @@ +export { InputTime } from './InputTime'; diff --git a/frontend/src/Components/index.ts b/frontend/src/Components/index.ts index fc0f4b0d3..5b9bac669 100644 --- a/frontend/src/Components/index.ts +++ b/frontend/src/Components/index.ts @@ -22,6 +22,7 @@ export { ImageList } from './ImageList'; export { ImageQuery } from './ImageQuery'; export { InputField } from './InputField'; export { InputFile } from './InputFile'; +export { InputTime } from './InputTime'; export { Link } from './Link'; export { List } from './List'; export { Modal } from './Modal'; diff --git a/frontend/src/PagesAdmin/OpeningHoursAdminPage/OpeningHoursAdminPage.module.scss b/frontend/src/PagesAdmin/OpeningHoursAdminPage/OpeningHoursAdminPage.module.scss index c060d8f69..6af654bd8 100644 --- a/frontend/src/PagesAdmin/OpeningHoursAdminPage/OpeningHoursAdminPage.module.scss +++ b/frontend/src/PagesAdmin/OpeningHoursAdminPage/OpeningHoursAdminPage.module.scss @@ -18,12 +18,12 @@ $bg-dark: #272727; } .venue_box { - border-radius: .4em; + border-radius: 0.4em; background-color: $white; border: 1px solid $grey-4; overflow: hidden; min-width: 16em; - max-width: 16em; + max-width: 20em; @include theme-dark { border-color: $black; @@ -33,7 +33,7 @@ $bg-dark: #272727; } .venue_header { - padding: .5em .75em; + padding: 0.5em 0.75em; border-bottom: 1px solid $header-border; font-size: 1.25em; font-weight: 700; @@ -54,7 +54,7 @@ $bg-dark: #272727; flex-direction: column; justify-content: space-between; width: 100%; - gap: .5em; + gap: 0.5em; } .day_row { @@ -72,9 +72,5 @@ $bg-dark: #272727; .day_edit { @include flex-row; - gap: .5em; -} - -.invalid { - border: 2px solid red; + gap: 0.5em; } diff --git a/frontend/src/PagesAdmin/OpeningHoursAdminPage/OpeningHoursAdminPage.tsx b/frontend/src/PagesAdmin/OpeningHoursAdminPage/OpeningHoursAdminPage.tsx index 0caa9aff1..18a668145 100644 --- a/frontend/src/PagesAdmin/OpeningHoursAdminPage/OpeningHoursAdminPage.tsx +++ b/frontend/src/PagesAdmin/OpeningHoursAdminPage/OpeningHoursAdminPage.tsx @@ -1,7 +1,7 @@ -import classNames from 'classnames'; import { ChangeEvent, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; +import { InputTime } from '~/Components'; import { getVenues, putVenue } from '~/api'; import { VenueDto } from '~/dto'; import { KEY } from '~/i18n/constants'; @@ -14,7 +14,6 @@ export function OpeningHoursAdminPage() { const { t } = useTranslation(); const [venues, setVenues] = useState([]); const [saveTimer, setSaveTimer] = useState>({}); - const [invalid, setInvalid] = useState>({}); const [isLoading, setIsLoading] = useState(true); // We need a reference to read changed state inside timeout @@ -37,30 +36,14 @@ export function OpeningHoursAdminPage() { }, []); // Save venue change. - function saveVenue(venue: VenueDto, field: keyof VenueDto) { + function saveVenue(venue: VenueDto, field: keyof VenueDto, value: string) { // Get most recent edits if any - const recent = venueRef.current.filter((v) => v.id === venue.id)[0]; + const updatedVenues = venues.map((v) => (v.id === venue.id ? { ...v, [field]: value } : v)); + venueRef.current = updatedVenues; // Send field change to backend - putVenue(venue.id, { - [field]: recent[field], - }) - // Success. - .then(() => { - setInvalid({ - ...invalid, - [`${venue.id}_${field}`]: false, - }); - toast.success(t(KEY.common_update_successful)); - }) - // Failed - .catch((error) => { - setInvalid({ - ...invalid, - [`${venue.id}_${field}`]: true, - }); - toast.error(t(KEY.common_something_went_wrong)); - console.error(error); - }); + putVenue(venue.slug, { + [field]: value, + }); } // Update view model on field. @@ -77,7 +60,6 @@ export function OpeningHoursAdminPage() { } return v; }); - // Update state and reference. setVenues(newVenues); venueRef.current = newVenues; @@ -90,7 +72,7 @@ export function OpeningHoursAdminPage() { // Start a new save timer. const timer = setTimeout(() => { - saveVenue(venue, field); + saveVenue(venue, field, value); }, 1000); // Store timeout to allow cancel. @@ -114,29 +96,22 @@ export function OpeningHoursAdminPage() { {ALL_DAYS.map((day) => { const openField: keyof VenueDto = `opening_${day}`; const closeField: keyof VenueDto = `closing_${day}`; - // Error checking - const invalidOpen = invalid[`${venue.id}_${openField}`] === true; - const invalidClose = invalid[`${venue.id}_${closeField}`] === true; // Edit tools return (
{t(getDayKey(day))}
- saveVenue(venue, openField)} - className={classNames(invalidOpen && styles.invalid)} - /> - - - handleOnChange(venue, openField)} + onBlur={(formattedTime) => saveVenue(venue, openField, formattedTime)} + > +

-

+ saveVenue(venue, closeField)} - className={classNames(invalidClose && styles.invalid)} - /> + onChange={() => handleOnChange(venue, closeField)} + onBlur={(formattedTime) => saveVenue(venue, closeField, formattedTime)} + >
); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index bca6f0741..2b79ae2ba 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -136,8 +136,8 @@ export async function getVenue(id: string | number): Promise { return response.data; } -export async function putVenue(id: string | number, venue: Partial): Promise { - const url = BACKEND_DOMAIN + reverse({ pattern: ROUTES.backend.samfundet__venues_detail, urlParams: { pk: id } }); +export async function putVenue(slug: string | number, venue: Partial): Promise { + const url = BACKEND_DOMAIN + reverse({ pattern: ROUTES.backend.samfundet__venues_detail, urlParams: { slug: slug } }); const response = await axios.put(url, venue, { withCredentials: true }); return response.data; } diff --git a/frontend/src/dto.ts b/frontend/src/dto.ts index 6e387bb8c..7173019ee 100644 --- a/frontend/src/dto.ts +++ b/frontend/src/dto.ts @@ -62,6 +62,7 @@ export type ObjectPermissionDto = { export type VenueDto = { id: number; + slug: string; name: string; description?: string; floor?: number;