diff --git a/.bazelrc b/.bazelrc index 004bf062a0a9..390eeb762a26 100644 --- a/.bazelrc +++ b/.bazelrc @@ -8,6 +8,7 @@ test --test_output=errors build --show_result=20 build --reuse_sandbox_directories build --nolegacy_external_runfiles +build --incompatible_strict_action_env build --noexperimental_check_output_files --experimental_allow_tags_propagation fetch --noexperimental_check_output_files --experimental_allow_tags_propagation query --noexperimental_check_output_files --experimental_allow_tags_propagation diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 8475a0064e2f..743512e24c1d 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -616,7 +616,7 @@ }, "@@rules_rust+//crate_universe:extension.bzl%crate": { "general": { - "bzlTransitiveDigest": "2bm8JGpVfTeP2/OEWLfaiC0BppLkZjpmq45/K90H3Zw=", + "bzlTransitiveDigest": "hCuKz2FMeqFDuYQoH1d3ptMTfAOR4t1WTwnn+cqGoWc=", "usagesDigest": "jdSuIdTp7Rqi17ioQocHd+ejglFQ3z+AyEWLmlCK/og=", "recordedFileInputs": { "@@rules_rust++rust_host_tools+rust_host_tools//bin/rustc": "241027b94ad67beb36d0356f7cc05180daaa9966b2958cfba1d4de089d2e521c", diff --git a/app/common/package.json b/app/common/package.json index fdd5227dd804..d5fb7e21cb8d 100644 --- a/app/common/package.json +++ b/app/common/package.json @@ -26,6 +26,7 @@ "@tanstack/vue-query": "5.59.20" }, "dependencies": { + "@internationalized/date": "3.7.0", "@tanstack/query-persist-client-core": "5.59.20", "@tanstack/vue-query": "5.59.20", "@types/node": "^20.11.21", diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index be64fc96e295..8032b70d3c04 100644 --- a/app/common/src/services/Backend.ts +++ b/app/common/src/services/Backend.ts @@ -381,20 +381,20 @@ export type ProjectParallelMode = (typeof PROJECT_PARALLEL_MODES)[number] export const PROJECT_EXECUTION_REPEAT_TYPES = [ 'none', - 'hourly', 'daily', - 'monthly-date', - 'monthly-weekday', - 'monthly-last-weekday', + 'weekly', + 'monthlyDate', + 'monthlyWeekday', + 'monthlyLastWeekday', ] as const export const PROJECT_EXECUTION_REPEAT_TYPE_TO_TEXT_ID = { none: 'noneProjectExecutionRepeatType', - hourly: 'hourlyProjectExecutionRepeatType', daily: 'dailyProjectExecutionRepeatType', - 'monthly-date': 'monthlyProjectExecutionRepeatType', - 'monthly-weekday': 'monthlyProjectExecutionRepeatType', - 'monthly-last-weekday': 'monthlyProjectExecutionRepeatType', + weekly: 'weeklyProjectExecutionRepeatType', + monthlyDate: 'monthlyProjectExecutionRepeatType', + monthlyWeekday: 'monthlyProjectExecutionRepeatType', + monthlyLastWeekday: 'monthlyProjectExecutionRepeatType', } satisfies { readonly [K in ProjectExecutionRepeatType]: TextId & `${string}ProjectExecutionRepeatType` } @@ -407,22 +407,20 @@ export interface ProjectExecutionNoneRepeatInfo { readonly type: 'none' } -/** Details for a project execution that repeats hourly. */ -export interface ProjectExecutionHourlyRepeatInfo { - readonly type: 'hourly' - readonly startHour: number - readonly endHour: number -} - /** Details for a project execution that repeats daily. */ export interface ProjectExecutionDailyRepeatInfo { readonly type: 'daily' +} + +/** Details for a project execution that repeats weekly on one or more days. */ +export interface ProjectExecutionWeeklyRepeatInfo { + readonly type: 'weekly' readonly daysOfWeek: readonly number[] } /** Details for a project execution that repeats monthly on a specific date. */ export interface ProjectExecutionMonthlyDateRepeatInfo { - readonly type: 'monthly-date' + readonly type: 'monthlyDate' readonly date: number readonly months: readonly number[] } @@ -432,7 +430,7 @@ export interface ProjectExecutionMonthlyDateRepeatInfo { * of a specific month. */ export interface ProjectExecutionMonthlyWeekdayRepeatInfo { - readonly type: 'monthly-weekday' + readonly type: 'monthlyWeekday' readonly weekNumber: number readonly dayOfWeek: number readonly months: readonly number[] @@ -443,14 +441,14 @@ export interface ProjectExecutionMonthlyWeekdayRepeatInfo { * of a specific month. */ export interface ProjectExecutionMonthlyLastWeekdayRepeatInfo { - readonly type: 'monthly-last-weekday' + readonly type: 'monthlyLastWeekday' readonly dayOfWeek: number readonly months: readonly number[] } export type ProjectExecutionRepeatInfo = - | ProjectExecutionHourlyRepeatInfo | ProjectExecutionDailyRepeatInfo + | ProjectExecutionWeeklyRepeatInfo | ProjectExecutionMonthlyDateRepeatInfo | ProjectExecutionMonthlyWeekdayRepeatInfo | ProjectExecutionMonthlyLastWeekdayRepeatInfo @@ -459,18 +457,21 @@ export type ProjectExecutionRepeatInfo = /** Metadata for a {@link ProjectExecution}. */ export interface ProjectExecutionInfo { readonly projectId: ProjectId - readonly timeZone: string readonly repeat: ProjectExecutionRepeatInfo - readonly parallelMode: ProjectParallelMode - readonly maxDurationMinutes: number readonly startDate: dateTime.Rfc3339DateTime + readonly endDate: dateTime.Rfc3339DateTime | null + readonly timeZone: string + readonly maxDurationMinutes: number + readonly parallelMode: ProjectParallelMode } /** A specific execution schedule of a project. */ export interface ProjectExecution extends ProjectExecutionInfo { - readonly enabled: boolean readonly executionId: ProjectExecutionId + readonly organizationId: OrganizationId readonly versionId: S3ObjectVersionId + readonly nextExecution: dateTime.Rfc3339DateTime + readonly projectSessions?: readonly ProjectSession[] } /** Metadata describing the location of an uploaded file. */ @@ -1824,6 +1825,10 @@ export default abstract class Backend { body: CreateProjectExecutionRequestBody, title: string, ): Promise + abstract getProjectExecutionDetails( + executionId: ProjectExecutionId, + title: string, + ): Promise abstract updateProjectExecution( executionId: ProjectExecutionId, body: UpdateProjectExecutionRequestBody, diff --git a/app/common/src/services/Backend/__test__/projectExecution.test.ts b/app/common/src/services/Backend/__test__/projectExecution.test.ts index 19201359d015..781aefc8bfdd 100644 --- a/app/common/src/services/Backend/__test__/projectExecution.test.ts +++ b/app/common/src/services/Backend/__test__/projectExecution.test.ts @@ -1,41 +1,23 @@ +import { ZonedDateTime } from '@internationalized/date' import * as v from 'vitest' import { toRfc3339 } from '../../../utilities/data/dateTime' import { ProjectExecutionInfo, ProjectId } from '../../Backend' import { firstProjectExecutionOnOrAfter, nextProjectExecutionDate } from '../projectExecution' -const HOURLY_EXECUTION_1: ProjectExecutionInfo = { - projectId: ProjectId('project-aaaaaaaa'), - repeat: { - type: 'hourly', - startHour: 7, - endHour: 15, - }, - startDate: toRfc3339(new Date(2020, 0, 1, 10, 59)), - timeZone: 'UTC', - maxDurationMinutes: 60, - parallelMode: 'ignore', -} +const TIME_ZONE = 'America/Los_Angeles' +const TIME_ZONE_WINTER_OFFSET = -28800000 +const TIME_ZONE_SUMMER_OFFSET = -25200000 -const HOURLY_EXECUTION_2: ProjectExecutionInfo = { +const WEEKLY_EXECUTION: ProjectExecutionInfo = { projectId: ProjectId('project-aaaaaaaa'), repeat: { - type: 'hourly', - startHour: 20, - endHour: 4, - }, - startDate: toRfc3339(new Date(2015, 2, 8, 22, 33)), - timeZone: 'UTC', - maxDurationMinutes: 60, - parallelMode: 'ignore', -} - -const DAILY_EXECUTION: ProjectExecutionInfo = { - projectId: ProjectId('project-aaaaaaaa'), - repeat: { - type: 'daily', + type: 'weekly', daysOfWeek: [0, 5], }, - startDate: toRfc3339(new Date(2000, 0, 1, 7, 3)), + startDate: toRfc3339( + new ZonedDateTime(2000, 1, 1, TIME_ZONE, TIME_ZONE_WINTER_OFFSET, 7, 3).toDate(), + ), + endDate: null, timeZone: 'UTC', maxDurationMinutes: 60, parallelMode: 'ignore', @@ -43,58 +25,45 @@ const DAILY_EXECUTION: ProjectExecutionInfo = { v.test.each([ { - info: DAILY_EXECUTION, - current: new Date(2000, 5, 4, 7, 3), - next1: new Date(2000, 5, 9, 7, 3), - next2: new Date(2000, 5, 11, 7, 3), - next3: new Date(2000, 5, 16, 7, 3), - }, - { - info: HOURLY_EXECUTION_1, - current: new Date(2022, 10, 21, 14, 59), - next1: new Date(2022, 10, 21, 15, 59), - next2: new Date(2022, 10, 22, 7, 59), - next3: new Date(2022, 10, 22, 8, 59), - }, - { - info: HOURLY_EXECUTION_2, - current: new Date(2018, 4, 11, 3, 33), - next1: new Date(2018, 4, 11, 4, 33), - next2: new Date(2018, 4, 11, 20, 33), - next3: new Date(2018, 4, 11, 21, 33), - }, - { - info: HOURLY_EXECUTION_2, - current: new Date(2018, 4, 11, 23, 33), - next1: new Date(2018, 4, 12, 0, 33), - next2: new Date(2018, 4, 12, 1, 33), - next3: new Date(2018, 4, 12, 2, 33), + info: WEEKLY_EXECUTION, + current: new ZonedDateTime(2000, 6, 4, TIME_ZONE, TIME_ZONE_SUMMER_OFFSET, 7, 3), + next1: new ZonedDateTime(2000, 6, 9, TIME_ZONE, TIME_ZONE_SUMMER_OFFSET, 7, 3), + next2: new ZonedDateTime(2000, 6, 11, TIME_ZONE, TIME_ZONE_SUMMER_OFFSET, 7, 3), + next3: new ZonedDateTime(2000, 6, 16, TIME_ZONE, TIME_ZONE_SUMMER_OFFSET, 7, 3), }, ] satisfies readonly { info: ProjectExecutionInfo - current: Date - next1: Date - next2: Date - next3: Date + current: ZonedDateTime + next1: ZonedDateTime + next2: ZonedDateTime + next3: ZonedDateTime }[])( 'Get next project execution date (current: $current)', ({ info, current, next1, next2, next3 }) => { - v.expect(nextProjectExecutionDate(info, current)).toStrictEqual(next1) - v.expect(nextProjectExecutionDate(info, next1)).toStrictEqual(next2) - v.expect(nextProjectExecutionDate(info, next2)).toStrictEqual(next3) + v.expect(nextProjectExecutionDate(info, current)?.toString()).toBe(next1.toString()) + v.expect(nextProjectExecutionDate(info, next1)?.toString()).toBe(next2.toString()) + v.expect(nextProjectExecutionDate(info, next2)?.toString()).toBe(next3.toString()) }, ) v.test.each([ - { info: DAILY_EXECUTION, current: new Date(1999, 1, 1), next: new Date(2000, 0, 2, 7, 3) }, - { info: DAILY_EXECUTION, current: new Date(2000, 10, 16), next: new Date(2000, 10, 17, 7, 3) }, + { + info: WEEKLY_EXECUTION, + current: new ZonedDateTime(1999, 1, 1, TIME_ZONE, TIME_ZONE_WINTER_OFFSET), + next: new ZonedDateTime(2000, 1, 2, TIME_ZONE, TIME_ZONE_WINTER_OFFSET, 7, 3), + }, + { + info: WEEKLY_EXECUTION, + current: new ZonedDateTime(2000, 11, 16, TIME_ZONE, TIME_ZONE_WINTER_OFFSET), + next: new ZonedDateTime(2000, 11, 17, TIME_ZONE, TIME_ZONE_WINTER_OFFSET, 7, 3), + }, ] satisfies readonly { info: ProjectExecutionInfo - current: Date - next: Date + current: ZonedDateTime + next: ZonedDateTime }[])( 'Get first project execution date on or after (current: $current)', ({ info, current, next }) => { - v.expect(firstProjectExecutionOnOrAfter(info, current)).toStrictEqual(next) + v.expect(firstProjectExecutionOnOrAfter(info, current).toString()).toBe(next.toString()) }, ) diff --git a/app/common/src/services/Backend/projectExecution.ts b/app/common/src/services/Backend/projectExecution.ts index 9c092571db20..442384bf5444 100644 --- a/app/common/src/services/Backend/projectExecution.ts +++ b/app/common/src/services/Backend/projectExecution.ts @@ -1,160 +1,135 @@ +import { ZonedDateTime, parseAbsolute } from '@internationalized/date' import { EMPTY_ARRAY } from '../../utilities/data/array' +import { DAYS_PER_WEEK, MONTHS_PER_YEAR, getDay } from '../../utilities/data/dateTime' import { ProjectExecutionInfo } from '../Backend' -const DAYS_PER_WEEK = 7 -const MONTHS_PER_YEAR = 12 +/** Positive modulo of the number with respect to the base. */ +function remainder(n: number, mod: number) { + return ((n % mod) + mod) % mod +} /** The first execution date of the given {@link ProjectExecution} on or after the given date. */ export function firstProjectExecutionOnOrAfter( projectExecution: ProjectExecutionInfo, - startDate: Date, -): Date { - // TODO: Account for timezone. - let nextDate = new Date(startDate) + startDate: ZonedDateTime, +): ZonedDateTime { + let nextDate = startDate const { repeat } = projectExecution - const executionStartDate = new Date(projectExecution.startDate) - if (nextDate < executionStartDate) { - nextDate = new Date(executionStartDate) + const executionStartDate = parseAbsolute( + new Date(projectExecution.startDate).toISOString(), + startDate.timeZone, + ) + if (nextDate.compare(executionStartDate) < 0) { + nextDate = executionStartDate } - nextDate.setMinutes(executionStartDate.getMinutes()) - if (repeat.type !== 'hourly') { - nextDate.setHours(executionStartDate.getHours()) + nextDate = nextDate.set({ + hour: executionStartDate.hour, + minute: executionStartDate.minute, + second: executionStartDate.second, + }) + if (nextDate.compare(startDate) < 0) { + nextDate = nextDate.add({ days: 1 }) } switch (repeat.type) { - case 'hourly': { - while (nextDate < startDate) { - nextDate.setHours(nextDate.getHours() + 1) - } - const currentHours = nextDate.getHours() - if (repeat.startHour < repeat.endHour) { - if (currentHours < repeat.startHour) { - nextDate.setHours(repeat.startHour) - } else if (currentHours > repeat.endHour) { - nextDate.setHours(repeat.startHour) - nextDate.setDate(nextDate.getDate() + 1) - } - } else { - if (currentHours > repeat.endHour && currentHours < repeat.startHour) { - nextDate.setHours(repeat.startHour) - } - } - break + case 'monthlyDate': + case 'monthlyWeekday': { + const currentMonth = nextDate.month + const month = repeat.months.find((month) => month >= currentMonth) ?? repeat.months[0] ?? 0 + const monthOffset = remainder(month - currentMonth, MONTHS_PER_YEAR) + nextDate = nextDate.add({ months: monthOffset }) + } + } + switch (repeat.type) { + case 'none': { + return parseAbsolute(projectExecution.startDate, startDate.timeZone) } case 'daily': { - const currentDay = nextDate.getDay() + break + } + case 'weekly': { + const currentDay = getDay(nextDate) const day = repeat.daysOfWeek.find((day) => day >= currentDay) ?? repeat.daysOfWeek[0] ?? 0 - const dayOffset = (day - currentDay + DAYS_PER_WEEK) % DAYS_PER_WEEK - nextDate.setDate(nextDate.getDate() + dayOffset) + const dayOffset = remainder(day - currentDay, DAYS_PER_WEEK) + nextDate = nextDate.add({ days: dayOffset }) break } - case 'monthly-weekday': { - const currentDate = nextDate.getDate() - nextDate.setDate(1) - nextDate.setDate(1 + (repeat.weekNumber - 1) * DAYS_PER_WEEK) - const currentDay = nextDate.getDay() + case 'monthlyWeekday': { + const currentDate = nextDate.day + nextDate = nextDate.set({ day: 1 + (repeat.weekNumber - 1) * DAYS_PER_WEEK }) + const currentDay = getDay(nextDate) const dayOffset = (repeat.dayOfWeek - currentDay + 7) % 7 - nextDate.setDate(nextDate.getDate() + dayOffset) - if (nextDate.getDate() < currentDate) { - nextDate.setDate(1) - nextDate.setMonth(nextDate.getMonth() + 1) - nextDate.setDate(1 + (repeat.weekNumber - 1) * DAYS_PER_WEEK) - const currentDay = nextDate.getDay() + nextDate = nextDate.add({ days: dayOffset }) + if (nextDate.day < currentDate) { + nextDate = nextDate.set({ day: 1 }) + nextDate = nextDate.add({ months: 1 }) + nextDate = nextDate.set({ day: 1 + (repeat.weekNumber - 1) * DAYS_PER_WEEK }) + const currentDay = getDay(nextDate) const dayOffset = (repeat.dayOfWeek - currentDay + 7) % 7 - nextDate.setDate(nextDate.getDate() + dayOffset) + nextDate = nextDate.add({ days: dayOffset }) } break } - case 'monthly-date': { - const currentDate = nextDate.getDate() + case 'monthlyDate': { + const currentDate = nextDate.day const date = repeat.date const goToNextMonth = date < currentDate - nextDate.setDate(date) + nextDate = nextDate.set({ day: date }) if (goToNextMonth) { - const startMonth = nextDate.getMonth() - nextDate.setMonth(startMonth + 1) - if ((nextDate.getMonth() + MONTHS_PER_YEAR - startMonth) % MONTHS_PER_YEAR > 1) { - nextDate.setDate(0) + const startMonth = nextDate.month + nextDate = nextDate.add({ months: 1 }) + if (remainder(nextDate.month - startMonth, MONTHS_PER_YEAR) > 1) { + nextDate = nextDate.set({ day: 1 }) } } break } } - switch (repeat.type) { - case 'hourly': - case 'daily': { - break - } - case 'monthly-date': - case 'monthly-weekday': { - const currentMonth = nextDate.getMonth() - const month = repeat.months.find((month) => month >= currentMonth) ?? repeat.months[0] ?? 0 - const monthOffset = (month - currentMonth + MONTHS_PER_YEAR) % MONTHS_PER_YEAR - nextDate.setMonth(nextDate.getMonth() + monthOffset) - } - } return nextDate } /** The next scheduled execution date of given {@link ProjectExecution}. */ -export function nextProjectExecutionDate(projectExecution: ProjectExecutionInfo, date: Date): Date { - // TODO: Account for timezone. - const nextDate = new Date(date) +export function nextProjectExecutionDate( + projectExecution: ProjectExecutionInfo, + date: ZonedDateTime, +): ZonedDateTime | null { + let nextDate = date const { repeat } = projectExecution switch (repeat.type) { - case 'hourly': { - nextDate.setHours(nextDate.getHours() + 1) - const currentHours = nextDate.getHours() - if (repeat.startHour < repeat.endHour) { - if (currentHours < repeat.startHour) { - nextDate.setHours(repeat.startHour) - } else if (currentHours > repeat.endHour) { - nextDate.setDate(nextDate.getDate() + 1) - nextDate.setHours(repeat.startHour) - } - } else { - if (currentHours > repeat.endHour && currentHours < repeat.startHour) { - nextDate.setHours(repeat.startHour) - } - } - break + case 'monthlyDate': + case 'monthlyWeekday': { + const currentMonth = nextDate.month + const month = repeat.months.find((month) => month > currentMonth) ?? repeat.months[0] ?? 0 + const monthOffset = remainder(month - currentMonth, MONTHS_PER_YEAR) + nextDate = nextDate.add({ months: monthOffset }) + } + } + switch (repeat.type) { + case 'none': + default: { + return null } case 'daily': { - const currentDay = nextDate.getDay() - const day = repeat.daysOfWeek.find((day) => day > currentDay) ?? repeat.daysOfWeek[0] ?? 0 - const dayOffset = ((day - currentDay + 6) % 7) + 1 - nextDate.setDate(nextDate.getDate() + dayOffset) + nextDate = nextDate.add({ days: 1 }) break } - case 'monthly-weekday': { - nextDate.setDate(1) - nextDate.setMonth(nextDate.getMonth() + 1) - nextDate.setDate(1 + (repeat.weekNumber - 1) * DAYS_PER_WEEK) - const currentDay = nextDate.getDay() - const dayOffset = ((repeat.dayOfWeek - currentDay + 6) % 7) + 1 - nextDate.setDate(nextDate.getDate() + dayOffset) + case 'weekly': { + const currentDay = getDay(nextDate) + const day = repeat.daysOfWeek.find((day) => day > currentDay) ?? repeat.daysOfWeek[0] ?? 0 + const dayOffset = ((day - currentDay + 6) % 7) + 1 + nextDate = nextDate.add({ days: dayOffset }) break } - case 'monthly-date': { - const startMonth = nextDate.getMonth() - nextDate.setMonth(startMonth + 1) - if ((nextDate.getMonth() + MONTHS_PER_YEAR - startMonth) % MONTHS_PER_YEAR > 1) { - nextDate.setDate(0) - } + case 'monthlyWeekday': { + nextDate = nextDate.set({ day: 1 + (repeat.weekNumber - 1) * DAYS_PER_WEEK }) + const currentDay = getDay(nextDate) + const dayOffset = remainder(repeat.dayOfWeek - currentDay - 1, DAYS_PER_WEEK) + 1 + nextDate = nextDate.add({ days: dayOffset }) break } - } - switch (repeat.type) { - case 'hourly': - case 'daily': { + case 'monthlyDate': { + nextDate = nextDate.set({ day: repeat.date }) break } - case 'monthly-date': - case 'monthly-weekday': { - const currentMonth = nextDate.getMonth() - const month = repeat.months.find((month) => month >= currentMonth) ?? repeat.months[0] ?? 0 - const monthOffset = (month - currentMonth + MONTHS_PER_YEAR) % MONTHS_PER_YEAR - nextDate.setMonth(nextDate.getMonth() + monthOffset) - } } return nextDate } @@ -166,17 +141,22 @@ export function nextProjectExecutionDate(projectExecution: ProjectExecutionInfo, */ export function getProjectExecutionRepetitionsForDateRange( projectExecution: ProjectExecutionInfo, - startDate: Date, - endDate: Date, -): readonly Date[] { + startDate: ZonedDateTime, + endDate: ZonedDateTime, +): readonly ZonedDateTime[] { + if (projectExecution.repeat.type === 'none') { + const soleExecutionDate = parseAbsolute(projectExecution.startDate, startDate.timeZone) + const isSoleExecutionWithinRange = + startDate.compare(soleExecutionDate) < 0 && endDate.compare(soleExecutionDate) > 0 + return isSoleExecutionWithinRange ? [soleExecutionDate] : [] + } const firstDate = firstProjectExecutionOnOrAfter(projectExecution, startDate) - if (firstDate >= endDate) { + if (firstDate.compare(endDate) > 0) { return EMPTY_ARRAY } - const repetitions: Date[] = [firstDate] - let currentDate = firstDate - currentDate = nextProjectExecutionDate(projectExecution, currentDate) - while (currentDate < endDate) { + const repetitions: ZonedDateTime[] = [firstDate] + let currentDate = nextProjectExecutionDate(projectExecution, firstDate) + while (currentDate != null && currentDate.compare(endDate) < 0) { repetitions.push(currentDate) currentDate = nextProjectExecutionDate(projectExecution, currentDate) } diff --git a/app/common/src/text.ts b/app/common/src/text.ts index 338311140895..adcda6c2a159 100644 --- a/app/common/src/text.ts +++ b/app/common/src/text.ts @@ -86,6 +86,7 @@ interface PlaceholderOverrides { readonly closeProjectBackendError: [projectTitle: string] readonly listProjectSessionsBackendError: [projectTitle: string] readonly createProjectExecutionBackendError: [projectTitle: string] + readonly getProjectExecutionDetailsBackendError: [projectTitle: string] readonly updateProjectExecutionBackendError: [projectTitle: string] readonly deleteProjectExecutionBackendError: [projectTitle: string] readonly listProjectExecutionsBackendError: [projectTitle: string] @@ -139,7 +140,6 @@ interface PlaceholderOverrides { readonly organizationNameSettingsInputDescription: [howLong: number] readonly trialDescription: [days: number] - readonly repeatsAtX: [dates: string] readonly xMinutes: [minutes: number] readonly xAm: [hour: string] readonly xPm: [hour: string] @@ -152,13 +152,14 @@ interface PlaceholderOverrides { readonly dateXTimeX: [date: string, time: string] readonly hourlyBetweenX: [startTime: string, endTime: string] readonly projectSessionsOnX: [date: string] - readonly xthDayOfMonth: [dateOrdinal: string] - readonly xthXDayOfMonth: [weekOrdinal: string, dayOfWeek: string] - readonly lastXDayOfMonth: [dayOfWeek: string] + readonly monthlyXthDay: [dateOrdinal: string] + readonly monthlyXthXDay: [weekOrdinal: string, dayOfWeek: string] + readonly monthlyLastXDay: [dayOfWeek: string] readonly repeatsTimeXMonthsXDateX: [time: string, months: string, date: string] readonly repeatsTimeXMonthsXDayXWeekX: [time: string, months: string, day: string, week: string] readonly repeatsTimeXMonthsXDayXLastWeek: [time: string, months: string, day: string] readonly xthWeek: [weekOrdinal: string] + readonly xExecutionsScheduledOnX: [count: number, date: string] readonly arbitraryFieldTooLarge: [maxSize: string] readonly arbitraryFieldTooSmall: [minSize: string] diff --git a/app/common/src/text/english.json b/app/common/src/text/english.json index 9d590e7907bc..cff7be6b156c 100644 --- a/app/common/src/text/english.json +++ b/app/common/src/text/english.json @@ -1,8 +1,5 @@ { - "submit": "Submit", - "retry": "Retry", - "hide": "Hide", - "more": "More", + "ellipsis": "...", "arbitraryFetchError": "An error occurred while fetching data", "arbitraryFetchImageError": "An error occurred while fetching an image", @@ -146,6 +143,7 @@ "closeProjectBackendError": "Could not close project '$0'", "listProjectSessionsBackendError": "Could not list sessions for project '$0'", "createProjectExecutionBackendError": "Could not create project execution for project '$0'", + "getProjectExecutionDetailsBackendError": "Could not update project execution for project '$0'", "updateProjectExecutionBackendError": "Could not update project execution for project '$0'", "deleteProjectExecutionBackendError": "Could not delete project execution for project '$0'", "listProjectExecutionsBackendError": "Could not list project executions for project '$0'", @@ -297,6 +295,9 @@ "options": "Options", "googleIcon": "Google icon", "gitHubIcon": "GitHub icon", + "submit": "Submit", + "retry": "Retry", + "hide": "Hide", "more": "More", "close": "Close", @@ -677,6 +678,7 @@ "repeatIntervalLabel": "Repeat", "monthlyRepeatTypeLabel": "Monthly repeat type", "firstOccurrenceLabel": "First occurrence", + "timeZoneLabel": "Time zone", "endAtLabel": "End at", "parallelModeLabel": "Parallel mode", "dateLabel": "Date", @@ -688,11 +690,11 @@ "weekOfMonthLabel": "Week of month", "minuteLabel": "Minute", "doesNotRepeat": "Does not repeat", - "hourly": "Hourly", "daily": "Daily", - "xthDayOfMonth": "$0 of month", - "xthXDayOfMonth": "$0 $1 of month", - "lastXDayOfMonth": "Last $0 of month", + "weekly": "Weekly", + "monthlyXthDay": "Monthly ($0)", + "monthlyXthXDay": "Monthly ($0 $1)", + "monthlyLastXDay": "Monthly (last $0)", "repeatsTimeXMonthsXDateX": "$0 $1 $2", "repeatsTimeXMonthsXDayXWeekX": "$0 $1 $2 $3", "repeatsTimeXMonthsXDayXLastWeek": "$0 $1 last $2", @@ -703,7 +705,6 @@ "ignoreParallelModeDescription": "Do nothing when trying to start an execution while one is already running.", "restartParallelModeDescription": "Stop the old execution before starting the new execution.", "parallelParallelModeDescription": "Run the new execution as well as the old execution.", - "hourlyBetweenX": "Hourly between $0-$1", "everyDaySuffix": "every day", "everyMonth": "Every month", "monday": "Monday", @@ -737,13 +738,14 @@ "pluralFew": "rd", "pluralOther": "th", "pleaseSelectATime": "Please select a time.", - "repeatsAtX": "Repeats at $0", - "maxDurationLabel": "Maximum duration (minutes)", + "repeatsAt": "Repeats at", + "maxDurationLabel": "Maximum duration", "maxDurationMinutesLabel": "Maximum duration (minutes)", "xMinutes": "$0 minute(s)", "xAm": "$0 am", "xPm": "$0 pm", "dateXTimeX": "$0 $1", + "xExecutionsScheduledOnX": "$0 execution(s) scheduled on $1 (click to view)", "currentlyEnabledLabel": "Enabled (click to disable)", "currentlyDisabledLabel": "Disabled (click to enable)", "updateExecutionToLatestVersionLabel": "Use latest project version", @@ -760,8 +762,8 @@ "downloadToExecuteWorkflow": "Download Enso to execute workflows.", "advancedOptions": "Advanced options", "noneProjectExecutionRepeatType": "No repeat", - "hourlyProjectExecutionRepeatType": "Hourly", "dailyProjectExecutionRepeatType": "Daily", + "weeklyProjectExecutionRepeatType": "Weekly", "monthlyProjectExecutionRepeatType": "Monthly", "xthWeek": "$0 week", diff --git a/app/common/src/utilities/data/dateTime.ts b/app/common/src/utilities/data/dateTime.ts index d4bd7dbe8607..4b346ac8d100 100644 --- a/app/common/src/utilities/data/dateTime.ts +++ b/app/common/src/utilities/data/dateTime.ts @@ -1,15 +1,24 @@ /** @file Utilities for manipulating and displaying dates and times. */ +import { ZonedDateTime, getDayOfWeek } from '@internationalized/date' import type { TextId } from '../../text' import { type Newtype, newtypeConstructor } from './newtype' +// 0 = Monday. Use `en-US` for 0 = Sunday. +const DAY_OF_WEEK_LOCALE = 'en-GB' +const ISO_FORMAT = Intl.DateTimeFormat('sv', { dateStyle: 'short', timeStyle: 'short' }) /** The number of hours in half a day. This is used to get the number of hours for AM/PM time. */ export const HALF_DAY_HOURS = 12 -/** The number of milliseconds in one minute. */ -export const MINUTE_MS = 60_000 export const MAX_DAYS_PER_MONTH = 31 export const DAYS_PER_WEEK = 7 -export const HOURS_PER_DAY = 24 -export const HOUR_MINUTE = 60 +export const MONTHS_PER_YEAR = 12 +export const MINUTE_MS = 60_000 +/** The number of minutes in an hour. */ +export const HOUR_MINUTES = 60 + +/** All possible day numbers. */ +export const DAYS = [...Array(DAYS_PER_WEEK).keys()] as const +/** All possible month numbers. */ +export const MONTHS = [...Array(MONTHS_PER_YEAR).keys()] as const /** A mapping from the month index returned by {@link Date.getMonth} to its full name. */ export const MONTH_NAMES = [ @@ -28,23 +37,23 @@ export const MONTH_NAMES = [ ] export const DAY_3_LETTER_TEXT_IDS = [ - 'sunday3', 'monday3', 'tuesday3', 'wednesday3', 'thursday3', 'friday3', 'saturday3', + 'sunday3', ] satisfies TextId[] export const DAY_TEXT_IDS = [ - 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', + 'sunday', ] satisfies TextId[] export const MONTH_3_LETTER_TEXT_IDS = [ @@ -62,6 +71,409 @@ export const MONTH_3_LETTER_TEXT_IDS = [ 'december3', ] satisfies TextId[] +export const WHITELISTED_TIME_ZONE_INFO = [ + { timeZone: 'Etc/GMT+12', description: 'International Date Line West' }, + { timeZone: 'Etc/GMT+11', description: 'Coordinated Universal Time-11' }, + { timeZone: 'Pacific/Midway', description: 'Midway' }, + { timeZone: 'Pacific/Niue', description: 'Niue' }, + { timeZone: 'Pacific/Pago_Pago', description: 'Pago_Pago' }, + { timeZone: 'Pacific/Samoa', description: 'Samoa' }, + { timeZone: 'America/Adak', description: 'Adak' }, + { timeZone: 'Pacific/Rarotonga', description: 'Rarotonga' }, + { timeZone: 'Pacific/Tahiti', description: 'Tahiti' }, + { timeZone: 'Pacific/Honolulu', description: 'Hawaii' }, + { timeZone: 'Pacific/Marquesas', description: 'Marquesas Islands' }, + { timeZone: 'America/Anchorage', description: 'Alaska' }, + { timeZone: 'Pacific/Gambier', description: 'Gambier' }, + { timeZone: 'America/Juneau', description: 'Juneau' }, + { timeZone: 'America/Nome', description: 'Nome' }, + { timeZone: 'America/Metlakatla', description: 'Metlakatla' }, + { timeZone: 'America/Sitka', description: 'Sitka' }, + { timeZone: 'America/Yakutat', description: 'Yakutat' }, + { timeZone: 'America/Tijuana', description: 'Baja California' }, + { timeZone: 'America/Los_Angeles', description: 'Pacific Time (US & Canada)' }, + { timeZone: 'Pacific/Pitcairn', description: 'Pitcairn' }, + { timeZone: 'America/Creston', description: 'Creston' }, + { timeZone: 'America/Chihuahua', description: 'Chihuahua, Mazatlan' }, + { timeZone: 'America/Dawson', description: 'Dawson' }, + { timeZone: 'America/Dawson_Creek', description: 'Dawson Creek' }, + { timeZone: 'America/Denver', description: 'Mountain Time (US & Canada)' }, + { timeZone: 'America/Ensenada', description: 'Ensenada' }, + { timeZone: 'America/Fort_Nelson', description: 'Fort Nelson' }, + { timeZone: 'America/Hermosillo', description: 'Hermosillo' }, + { timeZone: 'America/Vancouver', description: 'Vancouver' }, + { timeZone: 'America/Whitehorse', description: 'Whitehorse' }, + { timeZone: 'America/Phoenix', description: 'Arizona' }, + { timeZone: 'America/Bahia_Banderas', description: 'Bahia Banderas' }, + { timeZone: 'America/Belize', description: 'Belize' }, + { timeZone: 'America/Boise', description: 'Boise' }, + { timeZone: 'America/Costa_Rica', description: 'Costa Rica' }, + { timeZone: 'America/Chicago', description: 'Central Time (US & Canada)' }, + { timeZone: 'America/Edmonton', description: 'Edmonton' }, + { timeZone: 'America/El_Salvador', description: 'El Salvador' }, + { timeZone: 'America/Guatemala', description: 'Central America' }, + { timeZone: 'America/Managua', description: 'Managua' }, + { timeZone: 'America/Mazatlan', description: 'Mazatlan' }, + { timeZone: 'America/Ojinaga', description: 'Ojinaga' }, + { timeZone: 'America/Swift_Current', description: 'Swift Current' }, + { timeZone: 'America/Tegucigalpa', description: 'Tegucigalpa' }, + { timeZone: 'America/Mexico_City', description: 'Guadalajara, Mexico City, Monterrey' }, + { timeZone: 'America/Regina', description: 'Saskatchewan' }, + { timeZone: 'Pacific/Galapagos', description: 'Galapagos' }, + { timeZone: 'America/Atikokan', description: 'Atikokan' }, + { timeZone: 'America/Bogota', description: 'Bogota, Lima, Quito' }, + { timeZone: 'America/Cancun', description: 'Cancun' }, + { timeZone: 'America/Cayman', description: 'Cayman' }, + { timeZone: 'America/Coral_Harbour', description: 'Coral Harbour' }, + { timeZone: 'America/Eirunepe', description: 'Eirunepe' }, + { timeZone: 'America/Guayaquil', description: 'Guayaquil' }, + { timeZone: 'America/Indianapolis', description: 'Indiana (East)' }, + { timeZone: 'America/Jamaica', description: 'Jamaica' }, + { timeZone: 'America/Lima', description: 'Lima' }, + { timeZone: 'America/Matamoros', description: 'Matamoros' }, + { timeZone: 'America/Menominee', description: 'Menominee' }, + { timeZone: 'America/Merida', description: 'Merida' }, + { timeZone: 'America/Monterrey', description: 'Monterrey' }, + { timeZone: 'America/Nipigon', description: 'Nipigon' }, + { timeZone: 'America/Panama', description: 'Panama' }, + { timeZone: 'America/Rainy_River', description: 'Rainy River' }, + { timeZone: 'America/Rio_Branco', description: 'Rio Branco' }, + { timeZone: 'America/Thunder_Bay', description: 'Thunder Bay' }, + { timeZone: 'America/Winnipeg', description: 'Winnipeg' }, + { timeZone: 'America/New_York', description: 'Eastern Time (US & Canada)' }, + { timeZone: 'Pacific/Easter', description: 'Easter' }, + { timeZone: 'America/Caracas', description: 'Caracas' }, + { timeZone: 'America/Anguilla', description: 'Anguilla' }, + { timeZone: 'America/Antigua', description: 'Antigua' }, + { timeZone: 'America/Aruba', description: 'Aruba' }, + { timeZone: 'America/Asuncion', description: 'Asuncion' }, + { timeZone: 'America/Barbados', description: 'Barbados' }, + { timeZone: 'America/Boa_Vista', description: 'Boa Vista' }, + { timeZone: 'America/Campo_Grande', description: 'Campo Grande' }, + { timeZone: 'America/Curacao', description: 'Curacao' }, + { timeZone: 'America/Cuiaba', description: 'Cuiaba' }, + { timeZone: 'America/Detroit', description: 'Detroit' }, + { timeZone: 'America/Dominica', description: 'Dominica' }, + { timeZone: 'America/Grand_Turk', description: 'Grand Turk' }, + { timeZone: 'America/Grenada', description: 'Grenada' }, + { timeZone: 'America/Guadeloupe', description: 'Guadeloupe' }, + { timeZone: 'America/Guyana', description: 'Guyana' }, + { timeZone: 'America/Halifax', description: 'Atlantic Time (Canada)' }, + { timeZone: 'America/Havana', description: 'Havana' }, + { timeZone: 'America/La_Paz', description: 'Georgetown, La Paz, Manaus, San Juan' }, + { timeZone: 'America/Manaus', description: 'Manaus' }, + { timeZone: 'America/Martinique', description: 'Martinique' }, + { timeZone: 'America/Montreal', description: 'Montreal' }, + { timeZone: 'America/Montserrat', description: 'Montserrat' }, + { timeZone: 'America/Nassau', description: 'Nassau' }, + { timeZone: 'America/Port_of_Spain', description: 'Port of Spain' }, + { timeZone: 'America/Porto_Velho', description: 'Porto Velho' }, + { timeZone: 'America/Puerto_Rico', description: 'Puerto Rico' }, + { timeZone: 'America/Santo_Domingo', description: 'Santo Domingo' }, + { timeZone: 'America/St_Kitts', description: 'St. Kitts' }, + { timeZone: 'America/St_Lucia', description: 'St. Lucia' }, + { timeZone: 'America/St_Thomas', description: 'St. Thomas' }, + { timeZone: 'America/St_Vincent', description: 'St. Vincent' }, + { timeZone: 'America/Toronto', description: 'Toronto' }, + { timeZone: 'America/Tortola', description: 'Tortola' }, + { timeZone: 'America/Santiago', description: 'Santiago' }, + { timeZone: 'America/St_Johns', description: 'Newfoundland' }, + { timeZone: 'America/Araguaina', description: 'Araguaina' }, + { timeZone: 'America/Bahia', description: 'Bahia' }, + { timeZone: 'America/Belem', description: 'Belem' }, + { timeZone: 'America/Buenos_Aires', description: 'Buenos Aires' }, + { timeZone: 'America/Cayenne', description: 'Cayenne, Fortaleza' }, + { timeZone: 'America/Fortaleza', description: 'Fortaleza' }, + { timeZone: 'America/Glace_Bay', description: 'Glace Bay' }, + { timeZone: 'America/Goose_Bay', description: 'Goose Bay' }, + { timeZone: 'America/Godthab', description: 'Greenland' }, + { timeZone: 'America/Maceio', description: 'Maceio' }, + { timeZone: 'America/Moncton', description: 'Moncton' }, + { timeZone: 'America/Paramaribo', description: 'Paramaribo' }, + { timeZone: 'America/Punta_Arenas', description: 'Punta Arenas' }, + { timeZone: 'America/Recife', description: 'Recife' }, + { timeZone: 'America/Rosario', description: 'Rosario' }, + { timeZone: 'America/Santarem', description: 'Santarem' }, + { timeZone: 'America/Thule', description: 'Thule' }, + { timeZone: 'America/Montevideo', description: 'Montevideo' }, + { timeZone: 'America/Sao_Paulo', description: 'Brasilia' }, + { timeZone: 'Antarctica/Rothera', description: 'Rothera' }, + { timeZone: 'Atlantic/Bermuda', description: 'Bermuda' }, + { timeZone: 'Atlantic/Stanley', description: 'Stanley' }, + { timeZone: 'America/Miquelon', description: 'Miquelon' }, + { timeZone: 'America/Noronha', description: 'Noronha' }, + { timeZone: 'Atlantic/South_Georgia', description: 'South Georgia' }, + { timeZone: 'Etc/GMT+2', description: 'Coordinated Universal Time-02' }, + { timeZone: 'Atlantic/Azores', description: 'Azores' }, + { timeZone: 'Atlantic/Cape_Verde', description: 'Cape Verde' }, + { timeZone: 'UTC', description: 'Default' }, + { timeZone: 'America/Danmarkshavn', description: 'Danmarkshavn' }, + { timeZone: 'America/Scoresbysund', description: 'Scoresbysund' }, + { timeZone: 'Africa/Accra', description: 'Accra' }, + { timeZone: 'Africa/Abidjan', description: 'Abidjan' }, + { timeZone: 'Africa/Bamako', description: 'Bamako' }, + { timeZone: 'Africa/Banjul', description: 'Banjul' }, + { timeZone: 'Africa/Bissau', description: 'Bissau' }, + { timeZone: 'Africa/Conakry', description: 'Conakry' }, + { timeZone: 'Africa/Dakar', description: 'Dakar' }, + { timeZone: 'Africa/El_Aaiun', description: 'El Aaiun' }, + { timeZone: 'Africa/Freetown', description: 'Freetown' }, + { timeZone: 'Africa/Lome', description: 'Lome' }, + { timeZone: 'Africa/Monrovia', description: 'Monrovia' }, + { timeZone: 'Africa/Nouakchott', description: 'Nouakchott' }, + { timeZone: 'Africa/Ouagadougou', description: 'Ouagadougou' }, + { timeZone: 'Africa/Sao_Tome', description: 'Sao Tome' }, + { timeZone: 'Africa/Timbuktu', description: 'Timbuktu' }, + { timeZone: 'Antarctica/Troll', description: 'Troll' }, + { timeZone: 'Atlantic/St_Helena', description: 'St. Helena' }, + { timeZone: 'Atlantic/Reykjavik', description: 'Monrovia, Reykjavik' }, + { + timeZone: 'Europe/London', + description: 'Greenwich Mean Time, Dublin, Edinburgh, Lisbon, London', + }, + { timeZone: 'Africa/Bangui', description: 'Bangui' }, + { timeZone: 'Africa/Casablanca', description: 'Casablanca' }, + { timeZone: 'Africa/Algiers', description: 'Algiers' }, + { timeZone: 'Africa/Brazzaville', description: 'Brazzaville' }, + { timeZone: 'Africa/Douala', description: 'Douala' }, + { timeZone: 'Africa/Kinshasa', description: 'Kinshasa' }, + { timeZone: 'Africa/Libreville', description: 'Libreville' }, + { timeZone: 'Africa/Luanda', description: 'Luanda' }, + { timeZone: 'Africa/Malabo', description: 'Malabo' }, + { timeZone: 'Africa/Ndjamena', description: 'Ndjamena' }, + { timeZone: 'Africa/Niamey', description: 'Niamey' }, + { timeZone: 'Africa/Tunis', description: 'Tunis' }, + { timeZone: 'Africa/Lagos', description: 'West Central Africa' }, + { timeZone: 'Atlantic/Canary', description: 'Canary' }, + { timeZone: 'Atlantic/Faroe', description: 'Faroe' }, + { timeZone: 'Atlantic/Madeira', description: 'Madeira' }, + { timeZone: 'Europe/Belfast', description: 'Belfast' }, + { timeZone: 'Europe/Berlin', description: 'Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna' }, + { timeZone: 'Europe/Budapest', description: 'Belgrade, Bratislava, Budapest, Ljubljana, Prague' }, + { timeZone: 'Europe/Dublin', description: 'Dublin' }, + { timeZone: 'Europe/Guernsey', description: 'Guernsey' }, + { timeZone: 'Europe/Isle_of_Man', description: 'Isle of Man' }, + { timeZone: 'Europe/Jersey', description: 'Jersey' }, + { timeZone: 'Europe/Lisbon', description: 'Lisbon' }, + { timeZone: 'Europe/Paris', description: 'Brussels, Copenhagen, Madrid, Paris' }, + { timeZone: 'Europe/Warsaw', description: 'Sarajevo, Skopje, Warsaw, Zagreb' }, + { timeZone: 'Africa/Blantyre', description: 'Blantyre' }, + { timeZone: 'Africa/Bujumbura', description: 'Bujumbura' }, + { timeZone: 'Africa/Cairo', description: 'Cairo' }, + { timeZone: 'Africa/Ceuta', description: 'Ceuta' }, + { timeZone: 'Africa/Johannesburg', description: 'Johannesburg' }, + { timeZone: 'Africa/Gaborone', description: 'Gaborone' }, + { timeZone: 'Africa/Harare', description: 'Harare' }, + { timeZone: 'Africa/Khartoum', description: 'Khartoum' }, + { timeZone: 'Africa/Kigali', description: 'Kigali' }, + { timeZone: 'Africa/Lubumbashi', description: 'Lubumbashi' }, + { timeZone: 'Africa/Lusaka', description: 'Lusaka' }, + { timeZone: 'Africa/Maputo', description: 'Maputo' }, + { timeZone: 'Africa/Maseru', description: 'Maseru' }, + { timeZone: 'Africa/Mbabane', description: 'Mbabane' }, + { timeZone: 'Africa/Tripoli', description: 'Tripoli' }, + { timeZone: 'Africa/Windhoek', description: 'Windhoek' }, + { timeZone: 'Asia/Amman', description: 'Amman' }, + { timeZone: 'Asia/Beirut', description: 'Beirut' }, + { timeZone: 'Asia/Damascus', description: 'Damascus' }, + { timeZone: 'Asia/Jerusalem', description: 'Jerusalem' }, + { timeZone: 'Europe/Amsterdam', description: 'Amsterdam' }, + { timeZone: 'Europe/Andorra', description: 'Andorra' }, + { timeZone: 'Europe/Belgrade', description: 'Belgrade' }, + { timeZone: 'Europe/Brussels', description: 'Brussels' }, + { timeZone: 'Europe/Copenhagen', description: 'Copenhagen' }, + { timeZone: 'Europe/Gibraltar', description: 'Gibraltar' }, + { timeZone: 'Europe/Istanbul', description: 'Athens, Bucharest, Istanbul' }, + { timeZone: 'Europe/Kaliningrad', description: 'Kaliningrad' }, + { timeZone: 'Europe/Kiev', description: 'Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius' }, + { timeZone: 'Europe/Ljubljana', description: 'Ljubljana' }, + { timeZone: 'Europe/Luxembourg', description: 'Luxembourg' }, + { timeZone: 'Europe/Madrid', description: 'Madrid' }, + { timeZone: 'Europe/Malta', description: 'Malta' }, + { timeZone: 'Europe/Monaco', description: 'Monaco' }, + { timeZone: 'Europe/Minsk', description: 'Minsk' }, + { timeZone: 'Europe/Oslo', description: 'Oslo' }, + { timeZone: 'Europe/Prague', description: 'Prague' }, + { timeZone: 'Europe/Rome', description: 'Rome' }, + { timeZone: 'Europe/Sarajevo', description: 'Sarajevo' }, + { timeZone: 'Europe/Skopje', description: 'Skopje' }, + { timeZone: 'Europe/Stockholm', description: 'Stockholm' }, + { timeZone: 'Europe/Tirane', description: 'Tirane' }, + { timeZone: 'Europe/Vaduz', description: 'Vaduz' }, + { timeZone: 'Europe/Vienna', description: 'Vienna' }, + { timeZone: 'Europe/Zagreb', description: 'Zagreb' }, + { timeZone: 'Europe/Zurich', description: 'Zurich' }, + { timeZone: 'Africa/Addis_Ababa', description: 'Addis Ababa' }, + { timeZone: 'Africa/Asmara', description: 'Asmara' }, + { timeZone: 'Africa/Dar_es_Salaam', description: 'Dar es Salaam' }, + { timeZone: 'Africa/Djibouti', description: 'Djibouti' }, + { timeZone: 'Africa/Juba', description: 'Juba' }, + { timeZone: 'Africa/Kampala', description: 'Kampala' }, + { timeZone: 'Africa/Mogadishu', description: 'Mogadishu' }, + { timeZone: 'Africa/Nairobi', description: 'Nairobi' }, + { timeZone: 'Asia/Aden', description: 'Aden' }, + { timeZone: 'Asia/Bahrain', description: 'Bahrain' }, + { timeZone: 'Asia/Baghdad', description: 'Baghdad' }, + { timeZone: 'Asia/Famagusta', description: 'Famagusta' }, + { timeZone: 'Asia/Gaza', description: 'Gaza' }, + { timeZone: 'Asia/Hebron', description: 'Hebron' }, + { timeZone: 'Asia/Kuwait', description: 'Kuwait' }, + { timeZone: 'Asia/Nicosia', description: 'Nicosia' }, + { timeZone: 'Asia/Qatar', description: 'Qatar' }, + { timeZone: 'Asia/Tel_Aviv', description: 'Tel Aviv' }, + { timeZone: 'Asia/Riyadh', description: 'Kuwait, Riyadh' }, + { timeZone: 'Europe/Athens', description: 'Athens' }, + { timeZone: 'Europe/Bucharest', description: 'Bucharest' }, + { timeZone: 'Europe/Chisinau', description: 'Chisinau' }, + { timeZone: 'Europe/Helsinki', description: 'Helsinki' }, + { timeZone: 'Europe/Kirov', description: 'Kirov' }, + { timeZone: 'Europe/Moscow', description: 'Moscow, St. Petersburg, Volgograd' }, + { timeZone: 'Europe/Riga', description: 'Riga' }, + { timeZone: 'Europe/Simferopol', description: 'Simferopol' }, + { timeZone: 'Europe/Sofia', description: 'Sofia' }, + { timeZone: 'Europe/Tallinn', description: 'Tallinn' }, + { timeZone: 'Europe/Tiraspol', description: 'Tiraspol' }, + { timeZone: 'Europe/Uzhgorod', description: 'Uzhgorod' }, + { timeZone: 'Europe/Vilnius', description: 'Vilnius' }, + { timeZone: 'Europe/Volgograd', description: 'Volgograd' }, + { timeZone: 'Europe/Zaporozhye', description: 'Zaporozhye' }, + { timeZone: 'Indian/Antananarivo', description: 'Antananarivo' }, + { timeZone: 'Indian/Comoro', description: 'Comoro' }, + { timeZone: 'Indian/Mayotte', description: 'Mayotte' }, + { timeZone: 'Asia/Tehran', description: 'Tehran' }, + { timeZone: 'Asia/Baku', description: 'Baku' }, + { timeZone: 'Asia/Muscat', description: 'Muscat' }, + { timeZone: 'Asia/Dubai', description: 'Abu Dhabi, Muscat' }, + { timeZone: 'Asia/Tbilisi', description: 'Tbilisi' }, + { timeZone: 'Asia/Yerevan', description: 'Yerevan' }, + { timeZone: 'Europe/Astrakhan', description: 'Astrakhan' }, + { timeZone: 'Europe/Samara', description: 'Samara' }, + { timeZone: 'Europe/Saratov', description: 'Saratov' }, + { timeZone: 'Europe/Ulyanovsk', description: 'Ulyanovsk' }, + { timeZone: 'Indian/Mahe', description: 'Mahe' }, + { timeZone: 'Indian/Mauritius', description: 'Port Louis' }, + { timeZone: 'Indian/Reunion', description: 'Reunion' }, + { timeZone: 'Asia/Kabul', description: 'Kabul' }, + { timeZone: 'Antarctica/Mawson', description: 'Mawson' }, + { timeZone: 'Asia/Aqtau', description: 'Aqtau' }, + { timeZone: 'Asia/Aqtobe', description: 'Aqtobe' }, + { timeZone: 'Asia/Ashgabat', description: 'Ashgabat' }, + { timeZone: 'Asia/Atyrau', description: 'Atyrau' }, + { timeZone: 'Asia/Dushanbe', description: 'Dushanbe' }, + { timeZone: 'Asia/Oral', description: 'Oral' }, + { timeZone: 'Asia/Samarkand', description: 'Samarkand' }, + { timeZone: 'Asia/Karachi', description: 'Islamabad, Karachi' }, + { timeZone: 'Asia/Tashkent', description: 'Tashkent' }, + { timeZone: 'Asia/Yekaterinburg', description: 'Yekaterinburg' }, + { timeZone: 'Indian/Maldives', description: 'Maldives' }, + { timeZone: 'Asia/Calcutta', description: 'Chennai, Kolkata, Mumbai, New Delhi' }, + { timeZone: 'Asia/Colombo', description: 'Sri Jayawardenepura' }, + { timeZone: 'Asia/Kolkata', description: 'Kolkata' }, + { timeZone: 'Asia/Kathmandu', description: 'Kathmandu' }, + { timeZone: 'Asia/Almaty', description: 'Astana' }, + { timeZone: 'Asia/Bishkek', description: 'Bishkek' }, + { timeZone: 'Asia/Kashgar', description: 'Kashgar' }, + { timeZone: 'Asia/Omsk', description: 'Omsk' }, + { timeZone: 'Asia/Qyzylorda', description: 'Qyzylorda' }, + { timeZone: 'Asia/Thimphu', description: 'Thimphu' }, + { timeZone: 'Asia/Urumqi', description: 'Urumqi' }, + { timeZone: 'Asia/Dhaka', description: 'Dhaka' }, + { timeZone: 'Asia/Novosibirsk', description: 'Novosibirsk' }, + { timeZone: 'Indian/Chagos', description: 'Chagos' }, + { timeZone: 'Asia/Yangon', description: 'Yangon' }, + { timeZone: 'Asia/Rangoon', description: 'Yangon (Rangoon)' }, + { timeZone: 'Indian/Cocos', description: 'Cocos' }, + { timeZone: 'Antarctica/Davis', description: 'Davis' }, + { timeZone: 'Asia/Barnaul', description: 'Barnaul' }, + { timeZone: 'Asia/Bangkok', description: 'Bangkok, Hanoi, Jakarta' }, + { timeZone: 'Asia/Ho_Chi_Minh', description: 'Ho Chi Minh' }, + { timeZone: 'Asia/Hovd', description: 'Hovd' }, + { timeZone: 'Asia/Jakarta', description: 'Jakarta' }, + { timeZone: 'Asia/Novokuznetsk', description: 'Novokuznetsk' }, + { timeZone: 'Asia/Phnom_Penh', description: 'Phnom Penh' }, + { timeZone: 'Asia/Pontianak', description: 'Pontianak' }, + { timeZone: 'Asia/Tomsk', description: 'Tomsk' }, + { timeZone: 'Asia/Vientiane', description: 'Vientiane' }, + { timeZone: 'Asia/Krasnoyarsk', description: 'Krasnoyarsk' }, + { timeZone: 'Indian/Christmas', description: 'Christmas' }, + { timeZone: 'Antarctica/Casey', description: 'Casey' }, + { timeZone: 'Asia/Brunei', description: 'Brunei' }, + { timeZone: 'Asia/Choibalsan', description: 'Choibalsan' }, + { timeZone: 'Asia/Chongqing', description: 'Chongqing' }, + { timeZone: 'Asia/Harbin', description: 'Harbin' }, + { timeZone: 'Asia/Hong_Kong', description: 'Hong Kong' }, + { timeZone: 'Asia/Kuala_Lumpur', description: 'Kuala Lumpur' }, + { timeZone: 'Asia/Kuching', description: 'Kuching' }, + { timeZone: 'Asia/Macau', description: 'Macau' }, + { timeZone: 'Asia/Makassar', description: 'Makassar' }, + { timeZone: 'Asia/Manila', description: 'Manila' }, + { timeZone: 'Asia/Irkutsk', description: 'Irkutsk' }, + { timeZone: 'Asia/Shanghai', description: 'Beijing, Chongqing, Hong Kong, Urumqi' }, + { timeZone: 'Asia/Singapore', description: 'Kuala Lumpur, Singapore' }, + { timeZone: 'Asia/Taipei', description: 'Taipei' }, + { timeZone: 'Asia/Ulaanbaatar', description: 'Ulaanbaatar' }, + { timeZone: 'Australia/Perth', description: 'Perth' }, + { timeZone: 'Asia/Pyongyang', description: 'Pyongyang' }, + { timeZone: 'Australia/Eucla', description: 'Eucla' }, + { timeZone: 'Asia/Chita', description: 'Chita' }, + { timeZone: 'Asia/Dili', description: 'Dili' }, + { timeZone: 'Asia/Jayapura', description: 'Jayapura' }, + { timeZone: 'Asia/Khandyga', description: 'Khandyga' }, + { timeZone: 'Asia/Tokyo', description: 'Osaka, Sapporo, Tokyo' }, + { timeZone: 'Asia/Seoul', description: 'Seoul' }, + { timeZone: 'Asia/Yakutsk', description: 'Yakutsk' }, + { timeZone: 'Pacific/Palau', description: 'Palau' }, + { timeZone: 'Australia/Adelaide', description: 'Adelaide' }, + { timeZone: 'Australia/Broken_Hill', description: 'Broken Hill' }, + { timeZone: 'Australia/Darwin', description: 'Darwin' }, + { timeZone: 'Australia/Brisbane', description: 'Brisbane' }, + { timeZone: 'Asia/Vladivostok', description: 'Vladivostok' }, + { timeZone: 'Australia/Hobart', description: 'Hobart' }, + { timeZone: 'Australia/Lindeman', description: 'Lindeman' }, + { timeZone: 'Pacific/Chuuk', description: 'Chuuk' }, + { timeZone: 'Pacific/Saipan', description: 'Saipan' }, + { timeZone: 'Pacific/Port_Moresby', description: 'Guam, Port Moresby' }, + { timeZone: 'Australia/LHI', description: 'Lord Howe Island' }, + { timeZone: 'Australia/Lord_Howe', description: 'Lord Howe Island' }, + { timeZone: 'Asia/Sakhalin', description: 'Sakhalin' }, + { timeZone: 'Asia/Srednekolymsk', description: 'Srednekolymsk' }, + { timeZone: 'Asia/Magadan', description: 'Magadan, Solomon Is., New Caledonia' }, + { timeZone: 'Australia/Currie', description: 'Currie' }, + { timeZone: 'Australia/Melbourne', description: 'Melbourne' }, + { timeZone: 'Australia/Sydney', description: 'Sydney' }, + { timeZone: 'Pacific/Efate', description: 'Efate' }, + { timeZone: 'Pacific/Bougainville', description: 'Bougainville' }, + { timeZone: 'Pacific/Guadalcanal', description: 'Guadalcanal' }, + { timeZone: 'Pacific/Kosrae', description: 'Kosrae' }, + { timeZone: 'Pacific/Norfolk', description: 'Norfolk' }, + { timeZone: 'Pacific/Noumea', description: 'Noumea' }, + { timeZone: 'Pacific/Pohnpei', description: 'Pohnpei' }, + { timeZone: 'Asia/Anadyr', description: 'Anadyr' }, + { timeZone: 'Asia/Kamchatka', description: 'Petropavlovsk-Kamchatsky' }, + { timeZone: 'Etc/GMT-12', description: 'Coordinated Universal Time+12' }, + { timeZone: 'Pacific/Funafuti', description: 'Funafuti' }, + { timeZone: 'Pacific/Kwajalein', description: 'Kwajalein' }, + { timeZone: 'Pacific/Majuro', description: 'Majuro' }, + { timeZone: 'Pacific/Nauru', description: 'Nauru' }, + { timeZone: 'Pacific/Tarawa', description: 'Tarawa' }, + { timeZone: 'Pacific/Wake', description: 'Wake' }, + { timeZone: 'Pacific/Wallis', description: 'Wallis' }, + { timeZone: 'Pacific/Auckland', description: 'Auckland, Wellington' }, + { timeZone: 'Pacific/Fiji', description: 'Fiji' }, + { timeZone: 'Pacific/Chatham', description: 'Chatham Islands' }, + { timeZone: 'Pacific/Enderbury', description: 'Phoenix Islands, Tokelau, Tonga' }, + { timeZone: 'Pacific/Fakaofo', description: 'Fakaofo' }, + { timeZone: 'Pacific/Tongatapu', description: 'Tongatapu' }, + { timeZone: 'Pacific/Apia', description: 'Apia' }, + { timeZone: 'Pacific/Kiritimati', description: 'Line Islands' }, +] + +export const WHITELISTED_TIME_ZONES = WHITELISTED_TIME_ZONE_INFO.map(({ timeZone }) => timeZone) + +export const WHITELISTED_TIME_ZONE_MAP = new Map( + WHITELISTED_TIME_ZONE_INFO.map((info) => [info.timeZone, info]), +) + /** A string with date and time, following the RFC3339 specification. */ export type Rfc3339DateTime = Newtype /** Create a {@link Rfc3339DateTime}. */ @@ -111,12 +523,46 @@ export function toRfc3339(date: Date) { return Rfc3339DateTime(date.toISOString()) } -/** Convert a UTC date to a local date. */ -export function localDateToUtcDate(date: Date) { - return new Date(Number(date) + date.getTimezoneOffset() * MINUTE_MS) +/** Format a {@link Date} as a human-readable ISO string (`YYYY-MM-DD HH:mm`). */ +export function toReadableIsoString(date: Date, timeZone?: string) { + const formatter = + timeZone == null ? ISO_FORMAT : ( + Intl.DateTimeFormat('sv', { dateStyle: 'short', timeStyle: 'short', timeZone }) + ) + return formatter.format(date) +} + +/** Format a {@link ZonedDateTime} as a {@link Rfc3339DateTime}. */ +export function zonedDateTimeToReadableIsoString(date: ZonedDateTime) { + const isoString = date.toString() + const [, dateString, hour, minute] = isoString.match(/(.+)T(\d+):(\d+)/) ?? [] + return `${dateString} ${hour}:${minute}` +} + +/** Get a consistent day number for the day of week no matter the locale of the local device. */ +export function getDay(date: ZonedDateTime) { + return getDayOfWeek(date, DAY_OF_WEEK_LOCALE) +} + +/** + * Get the number of times this day of the week has occurred so far this month. + * Dates 1 through 7 are week 1, 8 through 14 are week 2, etc. + */ +export function getWeekOfMonth(day: number) { + return Math.floor((day - 1) / DAYS_PER_WEEK) + 1 +} + +/** Get a string representing the timezone offset of a date. */ +export function getTimeZoneOffsetString(date: ZonedDateTime) { + const offsetMin = date.offset / MINUTE_MS + const offsetNegative = offsetMin < 0 + const absoluteOffsetMin = Math.abs(offsetMin) + const offsetHours = Math.floor(absoluteOffsetMin / HOUR_MINUTES) + const offsetMinutes = absoluteOffsetMin % HOUR_MINUTES + return `${offsetNegative ? '-' : '+'}${`${offsetHours}`.padStart(2, '0')}:${`${offsetMinutes}`.padStart(2, '0')}` } -/** Convert a local date to a UTC date. */ -export function utcDateToLocalDate(date: Date) { - return new Date(Number(date) - date.getTimezoneOffset() * MINUTE_MS) +/** Get a string representing the timezone offset of a date, wrapped in `(GMT` and `)`. */ +export function getTimeZoneOffsetStringWithGMT(date: ZonedDateTime) { + return `(GMT${getTimeZoneOffsetString(date)})` } diff --git a/app/common/src/utilities/data/object.ts b/app/common/src/utilities/data/object.ts index 5599b6b3d3f2..394617a38ec4 100644 --- a/app/common/src/utilities/data/object.ts +++ b/app/common/src/utilities/data/object.ts @@ -1,21 +1,18 @@ /** @file Functions related to manipulating objects. */ -// =============== -// === Mutable === -// =============== - /** Remove the `readonly` modifier from all fields in a type. */ export type Mutable = { -readonly [K in keyof T]: T[K] } -// ============= -// === merge === -// ============= - /** Prevents generic parameter inference by hiding the type parameter behind a conditional type. */ type NoInfer = [T][T extends T ? 0 : never] +/** UNSAFE when `Key` is not a literal type. */ +export function unsafeKeyValuePair(key: Key, value: Value) { + return { [key]: value } as { [K in Key]: Value } +} + /** * Immutably shallowly merge an object with a partial update. * Does not preserve classes. Useful for preserving order of properties. diff --git a/app/gui/src/dashboard/components/AriaComponents/Form/components/useForm.ts b/app/gui/src/dashboard/components/AriaComponents/Form/components/useForm.ts index adf7517104ec..7d9971330a4d 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Form/components/useForm.ts +++ b/app/gui/src/dashboard/components/AriaComponents/Form/components/useForm.ts @@ -15,6 +15,7 @@ import { useOffline, useOfflineChange } from '#/hooks/offlineHooks' import { useText } from '#/providers/TextProvider' import * as errorUtils from '#/utilities/error' import { useMutation } from '@tanstack/react-query' +import { IS_DEV_MODE } from 'enso-common/src/detect' import * as schemaModule from './schema' import type * as types from './types' @@ -84,6 +85,10 @@ export function useForm( { async: true, errorMap: (issue) => { + if (IS_DEV_MODE) { + // eslint-disable-next-line no-restricted-properties + console.error('(Development only) Form validation error:', issue) + } switch (issue.code) { case 'too_small': if (issue.minimum === 1 && issue.type === 'string') { diff --git a/app/gui/src/dashboard/components/AriaComponents/Inputs/ComboBox/ComboBox.stories.tsx b/app/gui/src/dashboard/components/AriaComponents/Inputs/ComboBox/ComboBox.stories.tsx new file mode 100644 index 000000000000..1cfaae599874 --- /dev/null +++ b/app/gui/src/dashboard/components/AriaComponents/Inputs/ComboBox/ComboBox.stories.tsx @@ -0,0 +1,60 @@ +import { Text } from '#/components/AriaComponents/Text' +import { roundedVariants } from '#/components/AriaComponents/utilities' +import type { Meta, StoryObj } from '@storybook/react' +import { z } from 'zod' +import { Form } from '../../Form/index' +import type { ComboBoxProps } from './ComboBox' +import { ComboBox } from './ComboBox' + +type Props = ComboBoxProps +type Story = StoryObj + +const schema = z.object({ value: z.string() }) + +const sizes = ['medium', 'small'] as const +const roundeds = roundedVariants() + +export default { + title: 'Components/Inputs/ComboBox', + component: ComboBox, + render: (args) => , + tags: ['autodocs'], + decorators: [(Story, context) =>
{Story(context)}
], + args: { + name: 'value', + items: ['one', 'two', 'three'], + className: 'w-40', + children: (x: string) => x, + }, + parameters: { + layout: 'centered', + }, +} as Meta + +export const Default: Story = {} + +export const Rounded: Story = { + render: (_Story, context) => ( +
+ {roundeds.map((rounded) => ( +
+ + {rounded} +
+ ))} +
+ ), +} + +export const Size: Story = { + render: (_Story, context) => ( +
+ {sizes.map((size) => ( +
+ + {size} +
+ ))} +
+ ), +} diff --git a/app/gui/src/dashboard/components/AriaComponents/Inputs/ComboBox/ComboBox.tsx b/app/gui/src/dashboard/components/AriaComponents/Inputs/ComboBox/ComboBox.tsx index 6228c142b6d5..80f501315013 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Inputs/ComboBox/ComboBox.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Inputs/ComboBox/ComboBox.tsx @@ -1,22 +1,26 @@ /** @file A combo box with a list of items that can be filtered. */ -import { useContext, useMemo, type ForwardedRef } from 'react' +import { useContext, useRef, type ForwardedRef } from 'react' import CrossIcon from '#/assets/cross.svg' import ArrowIcon from '#/assets/folder_arrow.svg' import { ComboBox as AriaComboBox, ComboBoxStateContext, - Label, ListBox, ListBoxItem, type ComboBoxProps as AriaComboBoxProps, } from '#/components/aria' +import { useText } from '#/providers/TextProvider' +import { forwardRef } from '#/utilities/react' +import type { VariantProps } from '#/utilities/tailwindVariants' +import { tv } from '#/utilities/tailwindVariants' import { Button, Form, - Input, + makeRoundedStyles, Popover, Text, + UncontrolledInput, type FieldComponentProps, type FieldPath, type FieldProps, @@ -24,18 +28,23 @@ import { type FieldValues, type InputProps, type TSchema, -} from '#/components/AriaComponents' -import { useText } from '#/providers/TextProvider' -import { forwardRef } from '#/utilities/react' -import type { VariantProps } from '#/utilities/tailwindVariants' -import { tv } from '#/utilities/tailwindVariants' - -const POPOVER_CROSS_OFFSET_PX = -32 +} from '../..' const COMBO_BOX_STYLES = tv({ base: 'w-full', + variants: { + rounded: makeRoundedStyles('inputContainer'), + size: { + small: { + inputContainer: 'h-6 px-2', + }, + medium: { + inputContainer: 'h-8 px-4', + }, + }, + }, slots: { - inputContainer: 'flex items-center gap-2 px-1.5', + inputContainer: 'flex items-center gap-2 px-1.5 rounded-full border-0.5 border-primary/20', input: 'grow', resetButton: '', popover: 'py-2', @@ -44,6 +53,7 @@ const COMBO_BOX_STYLES = tv({ }, defaultVariants: { size: 'medium', + rounded: 'xlarge', }, }) @@ -61,10 +71,17 @@ export interface ComboBoxProps, 'className' | 'style'>, VariantProps, - Pick, 'placeholder'> { + Pick, 'addonEnd' | 'addonStart' | 'placeholder'> { /** This may change as the user types in the input. */ readonly items: readonly FieldValues[TFieldName][] + /** A text representation of the item to be shown on each option. */ readonly children: (item: FieldValues[TFieldName]) => string + /** + * Convert an item to a unique text id, if the default text format returned by + * `children` is not guaranteed (or not supposed) to be unique. + */ + readonly toTextValue?: (item: FieldValues[TFieldName]) => string + /** Hide the `x` button to disable resetting the input. */ readonly noResetButton?: boolean } @@ -83,19 +100,26 @@ export const ComboBox = forwardRef(function ComboBox< isDisabled, form, defaultValue, + defaultInputValue, + defaultSelectedKey, label, isRequired, className, placeholder, + size, + rounded, children, + toTextValue, noResetButton = false, variants = COMBO_BOX_STYLES, + addonStart, + addonEnd, } = props const itemsAreStrings = typeof items[0] === 'string' - const effectiveItems = useMemo( - () => (itemsAreStrings ? items.map((id) => ({ id })) : items), - [items, itemsAreStrings], - ) + const effectiveItems = itemsAreStrings ? items.map((id) => ({ id })) : items + const toTextValueOrText = toTextValue ?? children + const reverseMapping = new Map(items.map((item) => [toTextValueOrText(item), item])) + const popoverTriggerRef = useRef(null) const { fieldState, formInstance } = useStringField({ name, @@ -104,7 +128,7 @@ export const ComboBox = forwardRef(function ComboBox< defaultValue, }) - const styles = variants({}) + const styles = variants({ size, rounded }) return ( { - return ( - { - renderProps.field.onChange(key ?? '') - }} - > - -
-
- - - {(item) => { - const text = children( + render={(renderProps) => ( + { + renderProps.field.onChange(typeof key === 'string' ? reverseMapping.get(key) : null) + }} + > +
+
+ + + {(item) => { + // eslint-disable-next-line no-restricted-syntax + const fieldValue = ( + itemsAreStrings ? // @ts-expect-error When items are strings, they are mapped to // `{ id: item }`. - // eslint-disable-next-line no-restricted-syntax - (itemsAreStrings ? item.id : item) as FieldValues[TFieldName], - ) - return ( - - - {text} - - - ) - }} - - -
- ) - }} + item.id + : item) as FieldValues[TFieldName] + const text = children(fieldValue) + const textValue = toTextValue?.(fieldValue) ?? text + return ( + + + {text} + + + ) + }} +
+
+
+ )} />
) diff --git a/app/gui/src/dashboard/components/AriaComponents/Inputs/DatePicker/DatePicker.stories.tsx b/app/gui/src/dashboard/components/AriaComponents/Inputs/DatePicker/DatePicker.stories.tsx new file mode 100644 index 000000000000..662bac9cd99c --- /dev/null +++ b/app/gui/src/dashboard/components/AriaComponents/Inputs/DatePicker/DatePicker.stories.tsx @@ -0,0 +1,70 @@ +import { Text } from '#/components/AriaComponents/Text' +import { roundedVariants } from '#/components/AriaComponents/utilities' +import { CalendarDate, ZonedDateTime, now } from '@internationalized/date' +import type { Meta, StoryObj } from '@storybook/react' +import { z } from 'zod' +import { Form } from '../../Form/index' +import type { DatePickerProps } from './DatePicker' +import { DatePicker } from './DatePicker' + +type Props = DatePickerProps +type Story = StoryObj + +const schema = z.object({ value: z.instanceof(ZonedDateTime).or(z.instanceof(CalendarDate)) }) + +const sizes = ['medium', 'small'] as const +const roundeds = roundedVariants() + +export default { + title: 'Components/Inputs/DatePicker', + component: DatePicker, + render: (args) => , + tags: ['autodocs'], + decorators: [ + (Story, context) => ( +
+ {Story(context)} +
+ ), + ], + args: { name: 'value' }, + parameters: { + layout: 'centered', + }, +} as Meta + +export const Default: Story = {} + +export const WithTime: Story = { + args: { name: 'value', defaultValue: now('Etc/GMT+0') } satisfies Props, +} + +export const WithoutTimeZone: Story = { + args: { name: 'value', defaultValue: now('Etc/GMT+0'), hideTimeZone: true } satisfies Props, +} + +export const Rounded: Story = { + render: (_Story, context) => ( +
+ {roundeds.map((rounded) => ( +
+ + {rounded} +
+ ))} +
+ ), +} + +export const Size: Story = { + render: (_Story, context) => ( +
+ {sizes.map((size) => ( +
+ + {size} +
+ ))} +
+ ), +} diff --git a/app/gui/src/dashboard/components/AriaComponents/Inputs/DatePicker/DatePicker.tsx b/app/gui/src/dashboard/components/AriaComponents/Inputs/DatePicker/DatePicker.tsx index 18a3189d54b5..e0fd57661fba 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Inputs/DatePicker/DatePicker.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Inputs/DatePicker/DatePicker.tsx @@ -19,43 +19,47 @@ import { Dialog, Group, Heading, + I18nProvider, Label, type DatePickerProps as AriaDatePickerProps, type DateValue, } from '#/components/aria' +import { useText } from '#/providers/TextProvider' +import { forwardRef } from '#/utilities/react' +import type { VariantProps } from '#/utilities/tailwindVariants' +import { tv } from '#/utilities/tailwindVariants' import { Button, Form, Popover, Text, + makeRoundedStyles, type FieldComponentProps, type FieldPath, type FieldProps, type FieldStateProps, type FieldValues, type TSchema, -} from '#/components/AriaComponents' -import { useText } from '#/providers/TextProvider' -import { forwardRef } from '#/utilities/react' -import type { VariantProps } from '#/utilities/tailwindVariants' -import { tv } from '#/utilities/tailwindVariants' +} from '../..' const DATE_PICKER_STYLES = tv({ base: '', variants: { + rounded: makeRoundedStyles('inputContainer'), size: { small: { - inputGroup: 'h-6 px-2', + inputContainer: 'h-6 px-2', }, medium: { - inputGroup: 'h-8 px-4', + inputContainer: 'h-8 px-4', }, }, }, slots: { - inputGroup: 'flex items-center gap-2 rounded-full border-0.5 border-primary/20', - dateInput: 'flex justify-center grow', + inputContainer: 'flex items-center gap-2 rounded-full border-0.5 border-primary/20', + dateInput: 'flex justify-start grow order-2', dateSegment: 'rounded placeholder-shown:text-primary/30 focus:bg-primary/10 px-[0.5px]', + calendarButton: 'order-1 rotate-90', resetButton: '', calendarPopover: '', calendarDialog: 'text-primary text-xs mx-2', @@ -71,6 +75,7 @@ const DATE_PICKER_STYLES = tv({ }, defaultVariants: { size: 'medium', + rounded: 'xlarge', }, }) @@ -119,6 +124,10 @@ export const DatePicker = forwardRef(function DatePicker< size, variants = DATE_PICKER_STYLES, granularity, + isInvalid, + style, + rounded, + ...rest } = props const { fieldState, formInstance } = useDateValueField({ @@ -128,7 +137,7 @@ export const DatePicker = forwardRef(function DatePicker< defaultValue, }) - const styles = variants({ size }) + const styles = variants({ size, rounded }) return ( { - return ( - -