@contensis/forms
Version:
Render Contensis Forms with React
1 lines • 222 kB
Source Map (JSON)
{"version":3,"sources":["../src/components/Form.tsx","../src/state/captcha.ts","../src/state/version.ts","../src/state/api.ts","../src/state/dates/date-utils.ts","../src/state/dates/time-parsing.ts","../src/state/dates/locale.ts","../src/state/dates/date-parsing.ts","../src/state/dates/date-time-parsing.ts","../src/state/dates/dates.ts","../src/state/dates/index.ts","../src/state/errors.ts","../src/state/localisations.ts","../src/state/store.ts","../src/state/validation.ts","../src/state/fields.ts","../src/state/progress.ts","../src/state/form.ts","../src/state/rules.ts","../src/components/FormConfirmation.tsx","../src/components/FormRenderContext.tsx","../src/components/FormLoader.tsx","../src/components/FormButtons.tsx","../src/components/FormCurrentPage.tsx","../src/components/FormFieldContainer.tsx","../src/components/FormCheckbox.tsx","../src/components/FormFieldErrors.tsx","../src/components/utils.ts","../src/components/FormFieldFooter.tsx","../src/components/FormFieldInstructions.tsx","../src/components/html/renderer.ts","../src/components/html/Description.tsx","../src/components/html/Heading.tsx","../src/components/FormFieldLabel.tsx","../src/components/FormField.tsx","../src/components/FormFieldset.tsx","../src/components/inputs/CheckboxInput.tsx","../src/components/inputs/DateInput.tsx","../src/components/inputs/DatePartsInput.tsx","../src/components/inputs/DateTimeInput.tsx","../src/components/inputs/DateTimePartsInput.tsx","../src/components/inputs/DecimalInput.tsx","../src/components/inputs/EmailInput.tsx","../src/components/inputs/IntegerInput.tsx","../src/components/inputs/MultiSelectInput.tsx","../src/components/inputs/MultilineInput.tsx","../src/components/inputs/RadioInput.tsx","../src/components/inputs/ReferenceInput.tsx","../src/components/inputs/SelectInput.tsx","../src/components/inputs/TelInput.tsx","../src/components/inputs/TextInput.tsx","../src/components/inputs/TimeInput.tsx","../src/components/inputs/TimePartsInput.tsx","../src/components/inputs/UrlInput.tsx","../src/components/inputs/defaults.ts","../src/components/FormValidationSummary.tsx","../src/components/FormProgress.tsx","../src/components/FormTitle.tsx","../src/components/form-state.ts"],"sourcesContent":["import React, { FormEvent, FormEventHandler, MutableRefObject, ReactNode, useCallback, useEffect, useMemo, useState } from 'react';\nimport { FormLocalizations, FormPage, Nullable, VersionStatus } from '../models';\nimport { Api, Errors, Fields, Form, Progress, Rules } from '../state';\nimport { getPageTitle } from '../state/localisations';\nimport { FormConfirmation } from './FormConfirmation';\nimport { FormLoader } from './FormLoader';\nimport { DEFAULT_LANGUAGE, DEFAULT_LOCALIZATIONS, FormRenderContextProvider, mergeLocalizations } from './FormRenderContext';\nimport {\n FormHistory,\n FormState,\n isCurrentHistoryState,\n isValidHistoryState,\n PatchFormState,\n SetFormState,\n toHistoryState,\n useFormHtmlId,\n useFormState\n} from './form-state';\nimport { FormProps, SetFocussed, SetValue } from './models';\n\nfunction isServer() {\n return typeof window === `undefined`;\n}\n\nexport function ContensisForm(props: FormProps) {\n return isServer() ? null : <ClientFormContainer {...props} />;\n}\n\nfunction ClientFormContainer(props: FormProps) {\n const {\n apiUrl,\n disabled,\n error,\n formId,\n headingLevel,\n language,\n loading,\n localizations: localizationOverrides,\n showTitle,\n projectId,\n versionStatus,\n onLoadError,\n onPopulate,\n onSubmit,\n onSubmitError,\n onSubmitSuccess\n } = props;\n\n const [localizations, setLocalizations] = useState(DEFAULT_LOCALIZATIONS);\n const [formState, setFormState, patchFormState] = useFormState();\n\n const { errors, form, pageIndex, value } = formState;\n\n const pages: FormPage[] = useMemo(() => Form.getPages(form), [form]);\n const pageCount = pages.length;\n const currentPage = pages[pageIndex];\n const currentPageHasError = Form.pageHasErrors(currentPage, errors);\n\n useEffect(() => {\n async function loadForm(signal: AbortSignal) {\n const params = { apiUrl: apiUrl || '', projectId, formId, language: language || null, versionStatus: versionStatus || 'published' };\n try {\n const form = await Api.getForm(params, signal);\n\n const localizations = mergeLocalizations(localizationOverrides, form);\n setLocalizations(localizations);\n\n let initialValue = Form.getInitialValue(form, localizations);\n initialValue = await (onPopulate ? onPopulate(initialValue, form) : initialValue);\n const initialErrors = Form.validate(form, initialValue, localizations);\n\n patchFormState({\n form,\n isLoading: false,\n apiError: null,\n pageIndex: 0,\n value: initialValue,\n inputValue: Form.getInputValue(form, initialValue),\n showErrors: false,\n errors: initialErrors\n });\n } catch (apiError) {\n if (!signal.aborted) {\n if (onLoadError) {\n onLoadError(apiError, params);\n }\n patchFormState({\n isLoading: false,\n apiError\n });\n }\n }\n }\n\n patchFormState({ isLoading: true });\n const controller = new AbortController();\n loadForm(controller.signal);\n return () => {\n controller.abort();\n };\n }, [apiUrl, projectId, formId, language, versionStatus, localizationOverrides]);\n\n const onFormSubmit = async (e: FormEvent<HTMLFormElement>) => {\n e.preventDefault();\n if (!form) {\n return;\n }\n\n if (currentPageHasError) {\n patchFormState({ showErrors: true });\n return;\n }\n\n const isLastPage = !!pageCount && pageIndex === pageCount - 1;\n if (!isLastPage) {\n setFormState((prev) => ({\n ...prev,\n showErrors: false,\n pageIndex: prev.pageIndex + 1\n }));\n return;\n }\n\n patchFormState({ showErrors: false });\n const formResponse = await (onSubmit ? onSubmit(value, form) : value);\n if (!formResponse) {\n return;\n }\n\n try {\n const result = await Api.saveFormResponse({\n apiUrl: apiUrl || '',\n projectId,\n formId,\n language: language || null,\n versionStatus: versionStatus || 'published',\n formVersionNo: form?.version?.versionNo || '',\n captcha: form?.properties?.captcha,\n formResponse\n });\n const success = await (onSubmitSuccess ? onSubmitSuccess(result, form) : true);\n Progress.reset(form);\n if (success) {\n if (Rules.isConfirmationRuleReturnUri(result?.confirmation)) {\n window.location.assign(result.confirmation.link.sys.uri);\n } else {\n patchFormState({\n isSubmitted: true,\n formResponse: result.form,\n confirmationRule: result.confirmation\n });\n }\n }\n } catch (e) {\n const handleSubmitError = await (onSubmitError ? onSubmitError(e, form) : true);\n if (handleSubmitError) {\n Errors.handleError(e);\n patchFormState({ apiError: e });\n }\n }\n };\n\n const setValue = (id: string, value: unknown) => {\n const field = form?.fields.find((f) => f.id === id);\n if (field) {\n setFormState((prev) => {\n const fieldErrors = Fields.validate(field, value, form?.language || DEFAULT_LANGUAGE, localizations);\n const newValue = { ...prev.value, [id]: value };\n const newErrors = { ...prev.errors, [id]: fieldErrors };\n const newShowErrors = prev.showErrors && Form.pageHasErrors(currentPage, newErrors);\n return {\n ...prev,\n value: newValue,\n errors: newErrors,\n showErrors: newShowErrors,\n isDirty: true\n };\n });\n }\n };\n\n const setInputValue = (id: string, value: unknown) => {\n const field = form?.fields.find((f) => f.id === id);\n if (field) {\n setFormState((prev) => ({\n ...prev,\n inputValue: { ...prev.inputValue, [id]: value }\n }));\n }\n };\n\n const setFocussed = (_id: string, _focussed: boolean) => {\n // setFocussed((prev) => {\n // if (focussed) {\n // return id;\n // } else if (prev === id) {\n // return '';\n // }\n // return prev;\n // });\n };\n\n const previousPage = () => {\n setFormState((prev) => ({\n ...prev,\n pageIndex: Math.max(prev.pageIndex - 1, 0)\n }));\n };\n\n return (\n <FormRenderContextProvider language={form?.language || language} headingLevel={headingLevel} localizations={localizations}>\n <ClientForm\n apiUrl={apiUrl}\n currentPage={currentPage}\n currentPageHasError={currentPageHasError}\n disabled={disabled}\n formId={formId}\n formState={formState}\n language={language}\n loading={loading}\n localizations={localizations}\n showTitle={showTitle || false}\n pageCount={pageCount}\n pages={pages}\n projectId={projectId}\n versionStatus={versionStatus}\n error={error}\n patchFormState={patchFormState}\n setFormState={setFormState}\n setFocussed={setFocussed}\n setInputValue={setInputValue}\n setValue={setValue}\n onFormSubmit={onFormSubmit}\n previousPage={previousPage}\n />\n </FormRenderContextProvider>\n );\n}\n\ntype ClientFormProps = {\n apiUrl: Nullable<string>;\n currentPage: FormPage;\n currentPageHasError: boolean;\n disabled: ReactNode;\n formId: string;\n formState: FormState;\n language: Nullable<string>;\n loading: ReactNode;\n localizations: FormLocalizations;\n showTitle: boolean;\n pageCount: number;\n pages: FormPage[];\n projectId: string;\n versionStatus: undefined | VersionStatus;\n error: undefined | ((error: unknown) => ReactNode);\n patchFormState: PatchFormState;\n setFormState: SetFormState;\n setFocussed: SetFocussed;\n setInputValue: SetValue;\n setValue: SetValue;\n onFormSubmit: FormEventHandler<HTMLFormElement>;\n previousPage: () => void;\n};\n\nfunction ClientForm({\n apiUrl,\n currentPage,\n currentPageHasError,\n disabled,\n error,\n formId,\n formState,\n language,\n loading,\n localizations,\n showTitle,\n pageCount,\n pages,\n projectId,\n versionStatus,\n patchFormState,\n setFormState,\n setFocussed,\n setInputValue,\n setValue,\n onFormSubmit,\n previousPage\n}: ClientFormProps) {\n // todo: this has an issue in that form error messages are always using the default rather than the value specified in the form localisations\n //const { localizations: defaultLocalizations } = useContext(FormRenderContext);\n //const [formState, setFormState, patchFormState] = useFormState();\n const formHtmlId = useFormHtmlId(formId);\n\n const {\n defaultPageTitle,\n isLoading,\n apiError,\n form,\n pageIndex,\n value,\n inputValue,\n confirmationRule,\n formResponse,\n showErrors,\n errors,\n isDirty,\n isSubmitted\n } = formState;\n\n const inputRefs = useMemo(() => Fields.reduceFields(form, (): MutableRefObject<any> => ({ current: undefined })), [form]);\n\n const pageTitle = getPageTitle(defaultPageTitle, currentPage?.title, currentPage?.pageNo, pageCount, showErrors && currentPageHasError, localizations);\n\n useEffect(() => {\n document.title = pageTitle;\n }, [pageTitle]);\n\n useEffect(() => {\n if (form && isDirty && !isSubmitted) {\n Progress.autoSave(form, value);\n }\n }, [form, value, isDirty, isSubmitted]);\n\n /** Push an entry to the history stack so we can recall the previous state when navigating back and forward */\n useEffect(() => {\n const newState = toHistoryState(currentPage?.id, pageIndex);\n if (isValidHistoryState(newState) && !isCurrentHistoryState(newState)) {\n if (!isValidHistoryState(window.history.state)) {\n // Replace current history state with the initial form state when\n // the form initially renders to prevent a duplicate history entry\n window.history.replaceState(newState, '');\n } else {\n // Push state to history when changing pages so we can navigate back and forward\n window.history.pushState(newState, '');\n }\n }\n }, [pageIndex, currentPage]);\n\n const onPopState = useCallback(\n function (e: PopStateEvent) {\n if (isValidHistoryState(e.state)) {\n const newState = e.state as FormHistory;\n const newPage = pages[newState.pageIndex];\n if (newPage) {\n if (newState.pageIndex < pageIndex) {\n // back\n patchFormState({\n showErrors: false,\n pageIndex: newState.pageIndex\n });\n } else if (newState.pageIndex > pageIndex) {\n // forward\n if (currentPageHasError) {\n // current page not valid\n patchFormState({ showErrors: true });\n } else {\n setFormState((prev) => ({\n ...prev,\n showErrors: false,\n pageIndex: prev.pageIndex + 1\n }));\n }\n }\n }\n }\n },\n [pageIndex, pages, currentPageHasError]\n );\n\n useEffect(() => {\n const fn = onPopState;\n window.addEventListener('popstate', fn);\n return () => {\n window.removeEventListener('popstate', fn);\n };\n }, [onPopState]);\n\n return (\n <div className=\"contensis-form\">\n <div className=\"form\">\n {!confirmationRule ? (\n <FormLoader\n apiUrl={apiUrl}\n projectId={projectId}\n formId={formId}\n language={language}\n versionStatus={versionStatus}\n loading={loading}\n disabled={disabled}\n error={error}\n formHtmlId={formHtmlId}\n isLoading={isLoading}\n apiError={apiError}\n form={form}\n pageIndex={pageIndex}\n pageCount={pageCount}\n currentPage={currentPage}\n formValue={value}\n formInputValue={inputValue}\n showErrors={showErrors}\n showTitle={showTitle}\n showDescription={showTitle}\n formErrors={errors}\n inputRefs={inputRefs}\n setValue={setValue}\n setInputValue={setInputValue}\n setFocussed={setFocussed}\n previousPage={previousPage}\n onFormSubmit={onFormSubmit}\n />\n ) : null}\n {!!confirmationRule && !!formResponse ? <FormConfirmation rule={confirmationRule} formResponse={formResponse} /> : null}\n </div>\n </div>\n );\n}\n","import { CaptchaSettings, Nullable } from '../models';\n\nconst CAPTCHA_LOAD_CALLBACK = 'on_captcha_load';\n\ntype LoadCallback = (() => void) & { loaded: Promise<unknown> };\n\ntype ReCaptchaAction = {\n action: string;\n};\n\ntype ReCaptcha = {\n ready(fn: () => void): void;\n execute(siteKey: string, action: ReCaptchaAction): Promise<string>;\n};\n\ndeclare global {\n interface Window {\n [CAPTCHA_LOAD_CALLBACK]: LoadCallback;\n }\n\n var grecaptcha: ReCaptcha;\n}\n\nfunction getCaptchaUrl(siteKey: string) {\n // todo: possibly use alternative recaptcha url\n return `https://www.google.com/recaptcha/api.js?onload=${CAPTCHA_LOAD_CALLBACK}&render=${siteKey}`;\n}\n\nfunction load(captcha: Nullable<CaptchaSettings>) {\n if (captcha?.enabled && captcha?.siteKey) {\n ensureLoadCallback();\n\n const captchaUrl = getCaptchaUrl(captcha.siteKey);\n const head = document.getElementsByTagName('head')[0];\n const scripts = [...head.getElementsByTagName('script')];\n const scriptSrcs = scripts.map((s) => s.src);\n const hasCaptcha = scriptSrcs.includes(captchaUrl);\n\n if (!hasCaptcha) {\n const captcha = document.createElement('script');\n captcha.src = captchaUrl;\n head.appendChild(captcha);\n }\n }\n}\n\nasync function submit(formId: string, captcha: Nullable<CaptchaSettings>): Promise<string> {\n if (captcha?.enabled && captcha?.siteKey) {\n load(captcha);\n await window[CAPTCHA_LOAD_CALLBACK].loaded;\n await new Promise((resolve) => grecaptcha.ready(() => resolve(true)));\n return grecaptcha.execute(captcha.siteKey, { action: `${formId}_submit` });\n } else {\n return Promise.resolve('');\n }\n}\n\nfunction ensureLoadCallback() {\n if (!window[CAPTCHA_LOAD_CALLBACK]) {\n let loadedResolve: (v: unknown) => void;\n const loaded = new Promise((resolve) => {\n loadedResolve = resolve;\n });\n const callback = function () {\n loadedResolve(null);\n };\n Object.assign(callback, { loaded });\n window[CAPTCHA_LOAD_CALLBACK] = callback as LoadCallback;\n }\n}\n\nexport const Captcha = {\n load,\n submit\n};\n","import { Nullable, VersionStatus } from '../models';\n\nfunction isPublishedVersion(versionStatus: Nullable<VersionStatus>) {\n return !versionStatus || versionStatus === 'published';\n}\n\nexport const Version = {\n isPublishedVersion\n};\n","import { FormContentType, GetFormParams, SaveFormResponse, SaveFormResponseParams } from '../models';\nimport { Captcha } from './captcha';\nimport { Version } from './version';\n\nfunction getAllCookies(): { [key: string]: string } {\n return (document.cookie || '').split('; ').reduce(\n (cookies, cookieString) => {\n const parts = cookieString.split('=');\n const name = decode(parts[0]);\n if (name) {\n cookies[name] = decode(parts.slice(1).join('='));\n }\n return cookies;\n },\n {} as { [key: string]: string }\n );\n}\n\nfunction decode(s: string): string {\n try {\n return decodeURIComponent(s);\n } catch (e) {\n return s;\n }\n}\n\nfunction setCookie(key: string, value: string, expiry: Date) {\n document.cookie = `${encodeURIComponent(key)}=${encodeURIComponent(value)}; expires=${expiry.toUTCString()}; path=/;`;\n}\n\nconst CmsBearerTokenCookie = 'ContensisSecurityBearerToken';\nconst CmsRefreshTokenCookie = 'ContensisSecurityRefreshToken';\nconst RefreshTokenCookie = 'RefreshToken';\nconst RefreshTokenExpiryTime = 15 * 24 * 3600 * 1000; // 15 days\n\nconst SCOPE =\n 'Security_Administrator ContentType_Read ContentType_Write ContentType_Delete Entry_Read Entry_Write Entry_Delete Project_Read Project_Write Project_Delete Workflow_Administrator';\nconst GRANT_TYPE = 'contensis_classic_refresh_token';\n\ntype RequestOptions = {\n apiUrl: string;\n};\n\ntype AuthenticateResponse = {\n access_token: string;\n expires_in: number;\n refresh_token: string;\n token_type: string;\n};\n\nfunction isLoggedIn() {\n const cookies = getAllCookies();\n return !!cookies[CmsBearerTokenCookie];\n}\n\nasync function getBearerToken(options: RequestOptions) {\n const cookies = getAllCookies();\n if (cookies[CmsBearerTokenCookie]) {\n return cookies[CmsBearerTokenCookie];\n }\n const refreshToken = cookies[CmsRefreshTokenCookie] || cookies[RefreshTokenCookie];\n if (!refreshToken) {\n return null;\n }\n\n try {\n const currentDate = new Date();\n\n const response = await fetch(`${options.apiUrl}/authenticate/connect/token`, {\n headers: {\n 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8'\n },\n body: `scope=${encodeURIComponent(SCOPE)}&grant_type=${encodeURIComponent(GRANT_TYPE)}&refresh_token=${encodeURIComponent(refreshToken)}`,\n method: 'POST',\n mode: 'cors',\n credentials: 'omit'\n });\n\n if (!response.ok) {\n return null;\n }\n const data: AuthenticateResponse = await response.json();\n\n const bearerToken = data.access_token;\n const bearerTokenExpiryDate = new Date(currentDate.getTime() + data.expires_in * 1000);\n const newRefreshToken: string = data.refresh_token;\n const refreshTokenExpiryDate = new Date(currentDate.getTime() + RefreshTokenExpiryTime);\n\n setCookie(CmsBearerTokenCookie, bearerToken, bearerTokenExpiryDate);\n if (newRefreshToken) {\n setCookie(RefreshTokenCookie, newRefreshToken, refreshTokenExpiryDate);\n }\n return bearerToken;\n } catch (e) {\n return null;\n }\n}\n\nasync function getDefaultHeaders(options: RequestOptions) {\n const bearerToken = await getBearerToken(options);\n const headers: Record<string, string> = {\n 'content-type': 'application/json'\n };\n if (bearerToken) {\n headers.authorization = `Bearer ${bearerToken}`;\n }\n return headers;\n}\n\nasync function getForm({ apiUrl, projectId, formId, language, versionStatus }: GetFormParams, signal: AbortSignal) {\n const query = versionStatus === 'latest' ? `?versionStatus=${versionStatus}` : '';\n\n const headers = await getDefaultHeaders({ apiUrl });\n\n const response = await fetch(`${apiUrl}/api/forms/projects/${projectId}/contentTypes/${formId}/languages/${language || 'default'}${query}`, {\n headers,\n method: 'GET',\n mode: 'cors',\n signal\n });\n\n if (response.ok) {\n const result: FormContentType = await response.json();\n return result;\n } else {\n const error = await response.json();\n return Promise.reject({\n status: response.status,\n statusText: response.statusText,\n message: error?.message,\n error\n });\n }\n}\n\nasync function saveFormResponse({\n apiUrl,\n projectId,\n formId,\n language,\n formVersionNo,\n versionStatus,\n formResponse,\n captcha\n}: SaveFormResponseParams): Promise<SaveFormResponse> {\n const headers = await getDefaultHeaders({ apiUrl });\n const captchaResponse = Version.isPublishedVersion(versionStatus) && !isLoggedIn() ? await Captcha.submit(formId, captcha) : '';\n\n let url = `${apiUrl}/api/forms/projects/${projectId}/contentTypes/${formId}/languages/${language || 'default'}/entries`;\n url = Version.isPublishedVersion(versionStatus) && formVersionNo ? url : `${url}?contentTypePreviewVersion=${formVersionNo}`;\n\n const response = await fetch(url, {\n headers: {\n ...headers,\n ...(!!captchaResponse ? { 'g-recaptcha-response': captchaResponse } : {})\n },\n method: 'POST',\n body: JSON.stringify(formResponse),\n mode: 'cors'\n });\n\n if (response.ok) {\n const result: SaveFormResponse = await response.json();\n return result;\n } else {\n const error = await response.json();\n return Promise.reject({\n status: response.status,\n statusText: response.statusText,\n message: error?.message,\n error\n });\n }\n}\n\nexport const Api = {\n isLoggedIn,\n getForm,\n saveFormResponse\n};\n","export type DateFormat = 'dd-mm-yyyy' | 'mm-dd-yyyy' | 'yyyy-mm-dd';\nexport type TimeFormat = '12h' | '24h';\nexport type TimePeriod = 'am' | 'pm';\n\nexport type DateParts = {\n year?: string;\n month?: string;\n day?: string;\n};\n\nexport type DatePartKey = keyof DateParts;\n\nexport type DateValidation = {\n date: null | string;\n invalid?: DatePartKey[];\n};\n\nexport type TimeParts = {\n hour?: string;\n minute?: string;\n period?: TimePeriod;\n timeFormat?: TimeFormat;\n};\n\nexport type TimePartKey = keyof TimeParts;\n\nexport type TimeValidation = {\n time: null | string;\n invalid?: TimePartKey[];\n};\n\nexport type DateTimeParts = DateParts & TimeParts;\n\nexport type DateTimePartKey = keyof DateTimeParts;\n\nexport type DateTimeValidation = {\n datetime: null | string;\n invalid?: DateTimePartKey[];\n};\n\nexport function pad(n: number, length: number = 2) {\n const padding = Array.from({ length })\n .map(() => '0')\n .join('');\n return `${padding}${n}`.slice(-1 * length);\n}\n\nfunction padYear(year: number) {\n if (year >= 100) {\n return year;\n }\n // 2 digit year\n year = 1900 + year;\n const currentYear = new Date().getUTCFullYear();\n // if more than 80 years ago add 100 to put it in the future\n return currentYear - year > 80 ? year + 100 : year;\n}\n\nfunction toNumber(n: unknown): null | number {\n if (typeof n === 'number') {\n return n;\n }\n if (typeof n === 'string' && !!n) {\n const num = Number(n);\n if (!Number.isNaN(num)) {\n return num;\n }\n }\n return null;\n}\n\nfunction isWithinRange(n: null | number, min: number, max: number): n is number {\n if (typeof n === 'number') {\n return n >= min && n <= max;\n }\n return false;\n}\n\nexport const INVALID_DATE = 'Invalid date';\nexport const INVALID_TIME = 'Invalid time';\n\nexport function isDate(d: unknown): d is Date {\n return Object.prototype.toString.call(d) === '[object Date]';\n}\n\nexport function isValidDate(dt: Date) {\n return !Number.isNaN(Number(dt));\n}\n\nexport function validateDateTimeParts(parts: DateTimeParts): DateTimeValidation {\n if (!parts?.year && !parts?.month && !parts?.day && !parts?.hour && !parts?.minute) {\n return { datetime: null };\n }\n const { year, month, day, ...timeParts } = parts;\n const date = validateDateParts({ year, month, day });\n const time = validateTimeParts(timeParts);\n if (date.invalid || time.invalid) {\n return {\n datetime: INVALID_DATE,\n invalid: [...(date.invalid || []), ...(time.invalid || [])]\n };\n }\n // date: 0000-00-00T00:00\n // time: 00:00\n return {\n datetime: (date.date as string).substring(0, 11) + time.time\n };\n}\n\nexport function validateDateParts(parts: DateParts): DateValidation {\n if (!parts?.year && !parts?.month && !parts?.day) {\n return { date: null };\n }\n const year = toNumber(parts.year);\n const month = toNumber(parts.month);\n const day = toNumber(parts.day);\n\n const invalid: DatePartKey[] = [];\n\n if (!isWithinRange(year, 0, 3000)) {\n invalid.push('year');\n }\n if (!isWithinRange(month, 1, 12)) {\n invalid.push('month');\n }\n if (!isWithinRange(day, 1, 31)) {\n invalid.push('day');\n }\n\n if (!invalid.includes('year') && !invalid.includes('month') && !invalid.includes('day')) {\n const monthDays = daysInMonth(year as number, month as number);\n if ((day as number) > monthDays) {\n invalid.push('day');\n }\n }\n if (invalid.length) {\n return {\n date: INVALID_DATE,\n invalid\n };\n }\n return {\n date: `${padYear(year as number)}-${pad(month as number)}-${pad(day as number)}T00:00`\n };\n}\n\nexport function validateTimeParts(parts: TimeParts): TimeValidation {\n if (!parts?.hour && !parts?.minute) {\n return { time: null };\n }\n const hour = toNumber(parts.hour);\n const minute = toNumber(parts.minute);\n const { timeFormat, period } = parts;\n let hour24 = hour;\n\n const invalid: TimePartKey[] = [];\n if (timeFormat === '12h') {\n if (!isWithinRange(hour, 1, 12)) {\n invalid.push('hour');\n } else {\n if (hour === 12) {\n // 12am -> 0\n hour24 = period === 'am' ? 0 : hour;\n } else {\n // 1-11pm -> + 12\n hour24 = period === 'pm' ? hour + 12 : hour;\n }\n }\n } else {\n if (!isWithinRange(hour, 0, 23)) {\n invalid.push('hour');\n }\n }\n if (!isWithinRange(minute, 0, 59)) {\n invalid.push('minute');\n }\n\n if (invalid.length) {\n return {\n time: INVALID_TIME,\n invalid\n };\n }\n return {\n time: `${pad(hour24 as number)}:${pad(minute as number)}`\n };\n}\n\nfunction daysInMonth(year: number, month: number): number {\n const date = new Date(year, month, 1);\n date.setDate(0);\n return date.getDate();\n}\n","import { INVALID_TIME, pad } from './date-utils';\n\nexport function parseTime(time: string) {\n if (!time) {\n return time;\n }\n\n const match = [match3Digit, match4Digit].reduce(\n (prev, fn) => {\n return prev || fn(time);\n },\n null as null | TimeMatch\n );\n\n if (!match) {\n return INVALID_TIME;\n }\n\n let { hour, minute, period } = match;\n\n if (Number.isNaN(hour) || Number.isNaN(minute)) {\n return INVALID_TIME;\n }\n if (hour === 24) {\n if (minute === 0) {\n hour = 0;\n } else {\n return INVALID_TIME;\n }\n }\n if (hour === 0 && period === 'pm') {\n return INVALID_TIME;\n }\n\n if (period === 'am' && hour > 12) {\n // 17:00am\n return INVALID_TIME;\n }\n if (period === 'am' && hour === 12) {\n hour = 0;\n }\n if (period === 'pm' && hour < 12) {\n hour += 12;\n }\n\n if (hour > 24 || hour < 0) {\n return INVALID_TIME;\n }\n if (minute > 59 || minute < 0) {\n return INVALID_TIME;\n }\n\n return `${pad(hour)}:${pad(minute)}`;\n}\n\ntype TimeMatch = {\n hour: number;\n minute: number;\n period: null | 'am' | 'pm';\n};\n\nfunction match3Digit(time: string): null | TimeMatch {\n // match 1 and 3 digit times with no delimiters\n const timeRegex = /^(\\d{1,3})([ .])?(am|a.m|a.m.|pm|p.m|p.m.)?$/;\n const match = time.match(timeRegex);\n if (!match) {\n return null;\n }\n let t = Number(match[1]);\n if (Number.isNaN(t)) {\n return null;\n }\n let hour = 0;\n let minute = 0;\n const period = match[3];\n const isAm = !!period?.startsWith('a');\n const isPm = !!period?.startsWith('p');\n if (t < 100) {\n // 1 or 2 digit number this must be an hour values like 45/88 will fail\n hour = t;\n } else {\n hour = Math.floor(t / 100);\n minute = t % 100;\n }\n\n return {\n hour,\n minute,\n period: isAm ? 'am' : isPm ? 'pm' : null\n };\n}\n\nfunction match4Digit(time: string): null | TimeMatch {\n // match 4 digit times and times with delimiters\n const timeRegex = /^(\\d{1,2})([.: ])?(\\d{1,2})?([ .])?(am|a.m|am.|a.m.|pm|p.m|pm.|p.m.)?$/;\n const match = time.match(timeRegex);\n if (!match) {\n return null;\n }\n let hour = Number(match[1]);\n const minute = Number(match[3]);\n const period = match[5];\n const isAm = !!period?.startsWith('a');\n const isPm = !!period?.startsWith('p');\n\n return {\n hour,\n minute,\n period: isAm ? 'am' : isPm ? 'pm' : null\n };\n}\n\n// supported time formats\n\n/*\nfunction pad(n: number) {\n return (n < 10)\n ? `0${n}`\n : `${n}`;\n}\n\nfunction genTimes(hour: number, min: number, output: string) {\n const hourStr = pad(hour);\n let hour12 = (hour === 0) ? 12 : ((hour > 12) ? hour - 12 : hour);\n const minStr = pad(min);\n const amppm = (hour < 12) ? 'a' : 'p';\n const delimiters = [':', '', '.', ' '];\n const periods = [\n `${amppm}m`,\n ` ${amppm}m`,\n `.${amppm}m`,\n `${amppm}.m`,\n ` ${amppm}.m`,\n `.${amppm}.m`,\n `${amppm}.m.`,\n ` ${amppm}.m.`,\n `.${amppm}.m.`,\n ];\n\n const times = delimiters.reduce(\n (prev, delimiter) => {\n const t12 = `${hour12}${delimiter}${minStr}`;\n const t24 = `${hourStr}${delimiter}${minStr}`;\n\n if (hour > 0 && hour <= 12) {\n prev.push([t12, output]);\n }\n prev.push([t24, output]);\n\n periods.forEach(period => {\n prev.push([`${t12}${period}`, output]);\n prev.push([`${t24}${period}`, output]);\n });\n\n return prev;\n },\n [] as [string, string][]\n );\n return [\n ...times\n ];\n}\n\nconst TIMES = [\n ...genTimes(5, 15, '05:15'),\n ...genTimes(17, 44, '17:44'),\n ...genTimes(4, 0, '04:00'),\n ...genTimes(20, 0, '20:00'),\n ...genTimes(0, 5, '00:05'),\n ...genTimes(0, 0, '00:00'),\n ...genTimes(12, 0, '12:00'),\n ['24', '00:00'],\n ['2400', '00:00'],\n ['24:00', '00:00'],\n ['24 00', '00:00'],\n ['24.00', '00:00'],\n ['24:00am', '00:00'],\n ['24:00pm', ''],\n ['2401', ''],\n ['24:01', ''],\n ['24 01', ''],\n ['24.01', ''],\n ['24:01am', ''],\n ['24:01pm', ''],\n ['2801', ''],\n ['28:01', ''],\n ['28 01', ''],\n ['28.01', ''],\n ['28:01am', ''],\n ['28:01pm', ''],\n ['not a date', ''],\n ['5am', '05:00'],\n ['5pm', '17:00'],\n ['5', '05:00'],\n ['05', '05:00'],\n ['17', '17:00'],\n ['17am', ''],\n ['17pm', '17:00'],\n ['12', '12:00'],\n ['12am', '00:00'],\n ['12pm', '12:00'],\n ['0', '00:00'],\n ['00', '00:00'],\n ['00am', '00:00'],\n ['000am', '00:00'],\n ['00pm', ''],\n];\n*/\n","import { DatePartKey, DateParts, DateTimeParts, isDate, isValidDate, validateDateParts, validateDateTimeParts } from './date-utils';\nimport { parseTime } from './time-parsing';\n\nconst DEFAULT_LOCALE = 'default';\n\ntype LocaleInfo = {\n toShortDateString(input: number | string | Date): string;\n toShortDateTimeString(input: number | string | Date): string;\n shortDateMatchToParts(match: [string, string, string]): DateParts;\n shortDateTimeMatchToParts(match: [string, string, string, string, string]): DateTimeParts;\n formatters: DateFormatter[];\n};\n\nfunction isDatePartKey(key: string): key is DatePartKey {\n return key === 'day' || key === 'month' || key === 'year';\n}\n\nexport const localeInfo = (function () {\n let info: undefined | LocaleInfo = undefined;\n\n const createLocaleInfo = function (): LocaleInfo {\n const shortDateFormatter = new Intl.DateTimeFormat(DEFAULT_LOCALE, { dateStyle: 'short' });\n const shortTimeFormatter = new Intl.DateTimeFormat(DEFAULT_LOCALE, { timeStyle: 'short' });\n const shortDatePartsOrder = shortDateFormatter\n .formatToParts()\n .map((p) => p.type)\n .filter(isDatePartKey);\n\n const formatters = [dateFormatter('short'), dateFormatter('medium'), dateFormatter('long'), dateFormatter('full')];\n\n return {\n formatters,\n shortDateMatchToParts(match: [string, string, string]) {\n return {\n [shortDatePartsOrder[0]]: match[0],\n [shortDatePartsOrder[1]]: match[1],\n [shortDatePartsOrder[2]]: match[2]\n };\n },\n shortDateTimeMatchToParts(match: [string, string, string, string, string]) {\n return {\n [shortDatePartsOrder[0]]: match[0],\n [shortDatePartsOrder[1]]: match[1],\n [shortDatePartsOrder[2]]: match[2],\n hour: match[3],\n minute: match[4]\n };\n },\n toShortDateString(input: number | string | Date) {\n if (!input) {\n return '';\n }\n const dt = isDate(input) ? input : new Date(input);\n return isValidDate(dt) ? shortDateFormatter.format(dt) : '';\n },\n toShortDateTimeString(input: number | string | Date) {\n if (!input) {\n return '';\n }\n const dt = isDate(input) ? input : new Date(input);\n return isValidDate(dt) ? `${shortDateFormatter.format(dt)} ${shortTimeFormatter.format(dt)}` : '';\n }\n };\n };\n\n return function () {\n info = info || createLocaleInfo();\n return info;\n };\n})();\n\ntype DateFormatter = {\n parts: Intl.DateTimeFormatPart[];\n monthNames: string[];\n};\n\nfunction dateFormatter(dateStyle: 'short' | 'medium' | 'long' | 'full'): DateFormatter {\n const dtf = new Intl.DateTimeFormat(DEFAULT_LOCALE, { dateStyle });\n const parts = dtf.formatToParts();\n const monthNames = Array.from({ length: 12 }).map((_, i) => {\n const parts = dtf.formatToParts(new Date(2000, i, 1));\n const monthPart = parts.find((p) => p.type === 'month');\n return monthPart?.value || '';\n });\n return { parts, monthNames };\n}\n\nexport function parseDateFromFormatter(input: string, formatter: DateFormatter) {\n const pattern = createDateParsePattern(formatter.parts);\n const result: Partial<Record<Intl.DateTimeFormatPartTypes, string>> = {};\n for (const p of pattern) {\n if (p.literal) {\n const i = input.indexOf(p.literal);\n if (i >= 0) {\n result[p.type] = input.substring(0, i);\n input = input.substring(i + p.literal.length);\n }\n } else {\n result[p.type] = input;\n }\n }\n\n if (result.day && result.month && result.year) {\n // date time\n let month = Number(result.month);\n if (Number.isNaN(month)) {\n const index = formatter.monthNames.findIndex((m) => !!m && m?.toUpperCase() === result.month?.toUpperCase());\n month = index >= 0 ? index + 1 : month;\n }\n const dt = validateDateParts({\n year: result.year,\n month: `${month}`,\n day: result.day\n });\n if (!dt.invalid) {\n return dt;\n }\n }\n}\n\ntype Pattern = { type: Intl.DateTimeFormatPartTypes; literal?: string };\n\nfunction createDateParsePattern(parts: Intl.DateTimeFormatPart[]) {\n return parts.reduce((prev, part) => {\n if (part.type === 'literal') {\n if (!!prev[prev.length - 1]) {\n prev[prev.length - 1].literal = part.value;\n }\n } else {\n prev.push({ type: part.type });\n }\n return prev;\n }, [] as Pattern[]);\n}\n\nexport function parseDateTimeFromFormatter(input: string, formatter: DateFormatter) {\n const pattern = createDateTimeParsePattern(formatter.parts);\n const result: Partial<Record<Intl.DateTimeFormatPartTypes, string>> = {};\n for (const p of pattern) {\n if (p.literal) {\n const i = input.indexOf(p.literal);\n if (i >= 0) {\n result[p.type] = input.substring(0, i);\n input = input.substring(i + p.literal.length);\n }\n } else {\n result[p.type] = input;\n }\n }\n let time = '';\n if (input) {\n time = parseTime(input);\n if (!time) {\n return undefined;\n }\n }\n\n if (result.day && result.month && result.year) {\n // date time\n let month = Number(result.month);\n if (Number.isNaN(month)) {\n const index = formatter.monthNames.findIndex((m) => !!m && m?.toUpperCase() === result.month?.toUpperCase());\n month = index >= 0 ? index + 1 : month;\n }\n const hour = !!time ? time.split(':')[0] : undefined;\n const minute = !!time ? time.split(':')[1] : undefined;\n const dt = validateDateTimeParts({\n year: result.year,\n month: `${month}`,\n day: result.day,\n hour,\n minute\n });\n if (!dt.invalid) {\n return dt;\n }\n }\n}\n\nfunction createDateTimeParsePattern(parts: Intl.DateTimeFormatPart[]) {\n return parts.reduce((prev, part) => {\n if (part.type === 'literal') {\n if (!!prev[prev.length - 1]) {\n prev[prev.length - 1].literal = part.value;\n }\n } else {\n prev.push({ type: part.type, literal: ' ' });\n }\n return prev;\n }, [] as Pattern[]);\n}\n","import { DateValidation, INVALID_DATE, validateDateParts } from './date-utils';\nimport { localeInfo, parseDateFromFormatter } from './locale';\n\nconst ISO_REG_EX = /^([0-9]{4})-([0-1][0-9])(?:-([0-3][0-9]))?(?:[T ]?([0-2][0-9])(?::([0-5][0-9]))?(?::([0-5][0-9]))?)?(?:\\.([0-9]+))?(Z|(?:\\+|-)[0-9]{4})?$/;\n\nconst SHORT_DATE_REG_EX = /^(\\d{1,2})(\\/|-|\\.)(\\d{1,2})(\\/|-|\\.)(\\d{2,4})$/;\n\nexport function parseDate(date: string) {\n if (!date) {\n return date;\n }\n let d = parseIso(date);\n if (d) {\n return d.date;\n }\n d = parseShortDate(date);\n if (d) {\n return d.date;\n }\n d = localeInfo().formatters.reduce((prev, formatter) => prev || parseDateFromFormatter(date, formatter), undefined as undefined | DateValidation);\n if (d) {\n return d.date;\n }\n return INVALID_DATE;\n}\n\nfunction parseIso(date: string) {\n let match = date.match(ISO_REG_EX);\n if (match) {\n const year = match[1];\n const month = match[2];\n const day = match[3];\n return validateDateParts({ year, month, day });\n }\n}\n\nfunction parseShortDate(date: string) {\n let match = date.match(SHORT_DATE_REG_EX);\n if (match) {\n const dateParts = localeInfo().shortDateMatchToParts([match[1], match[3], match[5]]);\n return validateDateParts(dateParts);\n }\n}\n","import { DateTimeValidation, INVALID_DATE, validateDateTimeParts } from './date-utils';\nimport { localeInfo, parseDateTimeFromFormatter } from './locale';\n\nconst ISO_REG_EX = /^([0-9]{4})-([0-1][0-9])(?:-([0-3][0-9]))?(?:[T ]?([0-2][0-9])(?::([0-5][0-9]))?(?::([0-5][0-9]))?)?(?:\\.([0-9]+))?(Z|(?:\\+|-)[0-9]{4})?$/;\n\nconst SHORT_DATE_TIME_REG_EX = /^(\\d{1,2})(\\/|-|\\.)(\\d{1,2})(\\/|-|\\.)(\\d{2,4}) (\\d{1,2}):(\\d{1,2})$/;\n\nexport function parseDateTime(date: string) {\n if (!date) {\n return date;\n }\n let d = parseIso(date);\n if (d) {\n return d.datetime;\n }\n d = parseShortDateTime(date);\n if (d) {\n return d.datetime;\n }\n d = localeInfo().formatters.reduce((prev, formatter) => prev || parseDateTimeFromFormatter(date, formatter), undefined as undefined | DateTimeValidation);\n if (d) {\n return d.datetime;\n }\n return INVALID_DATE;\n}\n\nfunction parseIso(date: string) {\n let match = date.match(ISO_REG_EX);\n if (match) {\n const year = match[1];\n const month = match[2];\n const day = match[3];\n const hour = match[4];\n const minute = match[5];\n return validateDateTimeParts({ year, month, day, hour, minute, period: 'am', timeFormat: '24h' });\n }\n}\n\nfunction parseShortDateTime(date: string) {\n let match = date.match(SHORT_DATE_TIME_REG_EX);\n if (match) {\n const dateParts = localeInfo().shortDateTimeMatchToParts([match[1], match[3], match[5], match[6], match[7]]);\n return validateDateTimeParts(dateParts);\n }\n}\n","import { DateParts, DateTimeParts, isDate, isValidDate, TimeFormat, TimeParts, TimePeriod } from './date-utils';\n\nexport function getNowDate() {\n return toLocalIsoDate(new Date());\n}\n\nexport function getNowDateTime() {\n return toLocalIsoDateTime(new Date());\n}\n\nexport function getNowTime() {\n return toLocalIsoTime(new Date());\n}\n\nfunction toLocalIsoDate(dt: Date) {\n return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}T00:00`;\n}\n\nexport function toLocalIsoDateTime(dt: Date) {\n return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}`;\n}\n\nexport function toDateParts(input: number | string | Date): DateParts {\n const { year, month, day } = toDateTimeParts(input, '24h');\n return { year, month, day };\n}\n\nfunction timeToTimeParts(hour: number, minute: number, timeFormat: TimeFormat): TimeParts {\n let period: TimePeriod = 'am';\n if (timeFormat === '12h') {\n if (hour === 0) {\n hour = 12;\n } else if (hour === 12) {\n period = 'pm';\n } else if (hour > 12) {\n hour = hour - 12;\n period = 'pm';\n }\n }\n return {\n hour: timeFormat === '24h' ? pad(hour) : `${hour}`,\n minute: pad(minute),\n period,\n timeFormat\n };\n}\n\nexport function toDateTimeParts(input: number | string | Date, timeFormat: TimeFormat): DateTimeParts {\n if (!input) {\n return {\n year: '',\n month: '',\n day: '',\n hour: '',\n minute: '',\n period: 'am',\n timeFormat\n };\n }\n const dt = isDate(input) ? input : new Date(input);\n if (isValidDate(dt)) {\n return {\n year: `${dt.getFullYear()}`,\n month: `${dt.getMonth() + 1}`,\n day: `${dt.getDate()}`,\n ...timeToTimeParts(dt.getHours(), dt.getMinutes(), timeFormat)\n };\n } else {\n return {\n year: '',\n month: '',\n day: '',\n hour: '',\n minute: '',\n period: 'am',\n timeFormat\n };\n }\n}\n\nexport function toTimeParts(input: number | string | Date, timeFormat: TimeFormat): TimeParts {\n const dateParts = toDateTimeParts(input, timeFormat);\n if (dateParts.hour && dateParts.minute) {\n return {\n hour: dateParts.hour,\n minute: dateParts.minute,\n period: dateParts.period,\n timeFormat: dateParts.timeFormat\n };\n }\n if (typeof input === 'string') {\n const parts = input.split(':');\n if (parts.length === 2) {\n const hourNum = parseInt(parts[0], 10);\n const minuteNum = parseInt(parts[1], 10);\n if (!Number.isNaN(hourNum) && !Number.isNaN(minuteNum)) {\n return timeToTimeParts(hourNum, minuteNum, timeFormat);\n }\n }\n }\n return {\n hour: '',\n minute: '',\n period: 'am',\n timeFormat\n };\n}\n\nfunction toLocalIsoTime(dt: Date) {\n return `${pad(dt.getHours())}:${pad(dt.getMinutes())}`;\n}\n\nfunction pad(n: number, length: number = 2) {\n const padding = Array.from({ length })\n .map(() => '0')\n .join('');\n return `${padding}${n}`.slice(-1 * length);\n}\n","import { parseDate } from './date-parsing';\nimport { parseDateTime } from './date-time-parsing';\nimport { validateDateParts, validateDateTimeParts, validateTimeParts } from './date-utils';\nimport { getNowDate, getNowDateTime, getNowTime, toDateParts, toDateTimeParts, toLocalIsoDateTime, toTimeParts } from './dates';\nimport { localeInfo } from './locale';\nimport { parseTime } from './time-parsing';\nexport type { DateFormat, DateParts, DateTimeParts, TimeFormat, TimeParts, TimePeriod } from './date-utils';\n\nexport const DateTime = {\n getNowDate,\n getNowDateTime,\n getNowTime,\n localeInfo,\n parseDate,\n parseDateTime,\n parseTime,\n toDateParts,\n toDat