qa: invite modal dropdown + other + clamp limits

This commit is contained in:
Calum H. (IMB11)
2026-07-27 12:11:48 +01:00
parent af9cb8a625
commit 7741e7f2dd
2 changed files with 247 additions and 23 deletions
@@ -142,6 +142,7 @@ const props = withDefaults(
min?: number min?: number
max?: number max?: number
step?: number step?: number
clamp?: boolean
disabled?: boolean disabled?: boolean
readonly?: boolean readonly?: boolean
error?: boolean error?: boolean
@@ -159,6 +160,7 @@ const props = withDefaults(
type: 'text', type: 'text',
size: 'standard', size: 'standard',
variant: 'filled', variant: 'filled',
clamp: false,
disabled: false, disabled: false,
readonly: false, readonly: false,
error: false, error: false,
@@ -189,12 +191,22 @@ defineExpose({
function onInput(event: Event) { function onInput(event: Event) {
const target = event.target as HTMLInputElement | HTMLTextAreaElement const target = event.target as HTMLInputElement | HTMLTextAreaElement
model.value = if (props.type !== 'number' || props.multiline) {
props.type === 'number' && !props.multiline model.value = target.value
? target.value === '' return
? undefined }
: Number(target.value) if (target.value === '') {
: target.value model.value = undefined
return
}
let value = Number(target.value)
if (props.clamp) {
if (props.min !== undefined) value = Math.max(props.min, value)
if (props.max !== undefined) value = Math.min(props.max, value)
target.value = String(value)
}
model.value = value
} }
function clear() { function clear() {
@@ -1,19 +1,62 @@
<template> <template>
<NewModal ref="modal" :header="formatMessage(messages.title)" max-width="30rem"> <NewModal ref="modal" :header="formatMessage(messages.title)" width="420px" max-width="420px">
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ formatMessage(messages.expiryLabel) }}</span> <span class="font-semibold text-contrast">{{ formatMessage(messages.expiryLabel) }}</span>
<DatePicker <Combobox
v-model="expiry" :model-value="selectedExpiryPreset"
:options="expiryDropdownOptions"
:display-value="expiryPickerLabel"
:disabled="saving" :disabled="saving"
:min-date="minimumExpiry" :dropdown-min-width="customExpiryOpen ? '20rem' : undefined"
:max-date="maximumExpiry" :dropdown-class="customExpiryOpen ? 'bg-transparent border-0 -mt-1 pb-2 shadow-none' : ''"
date-format="Y-m-d H:i" @open="handleExpiryPickerOpen"
alt-format="F j, Y at h:i K" @close="handleExpiryPickerClose"
enable-time @select="selectExpiryPreset"
wrapper-class="w-full" >
input-class="w-full" <template #dropdown-footer>
/> <div
v-if="customExpiryOpen"
class="flex flex-col rounded-2xl border border-solid border-surface-5 bg-surface-3 p-1"
>
<DatePicker
v-model="customExpiry"
:min-date="minimumExpiry"
:max-date="maximumExpiry"
:default-view-date="customExpiry || minimumExpiry"
date-format="Y-m-d H:i"
enable-time
calendar-only
wrapper-class="w-full"
calendar-class="!border-none"
/>
<div class="flex justify-end gap-2 p-3 pt-1">
<ButtonStyled type="outlined">
<button type="button" @click="cancelCustomExpiry">
{{ formatMessage(messages.cancel) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
type="button"
:disabled="!canApplyCustomExpiry"
@click="applyCustomExpiry"
>
{{ formatMessage(messages.apply) }}
</button>
</ButtonStyled>
</div>
</div>
<button
v-else
type="button"
class="flex w-full cursor-pointer items-center border-0 border-t border-solid border-surface-5 bg-transparent px-4 py-3 text-left text-base font-semibold leading-tight text-primary transition-colors hover:bg-surface-5"
@click.stop="openCustomExpiry"
>
{{ formatMessage(messages.customExpiry) }}
</button>
</template>
</Combobox>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ formatMessage(messages.maxUsesLabel) }}</span> <span class="font-semibold text-contrast">{{ formatMessage(messages.maxUsesLabel) }}</span>
@@ -24,6 +67,7 @@
:max="maximumUses" :max="maximumUses"
:step="1" :step="1"
:disabled="saving || maximumUses === 0" :disabled="saving || maximumUses === 0"
clamp
/> />
</div> </div>
</div> </div>
@@ -51,14 +95,30 @@
import { SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets' import { SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useFormatDateTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n' import { defineMessages, useVIntl } from '../../../composables/i18n'
import { injectNotificationManager } from '../../../providers' import { injectNotificationManager } from '../../../providers'
import ButtonStyled from '../../base/ButtonStyled.vue' import ButtonStyled from '../../base/ButtonStyled.vue'
import Combobox, { type ComboboxOption } from '../../base/Combobox.vue'
import DatePicker from '../../base/DatePicker.vue' import DatePicker from '../../base/DatePicker.vue'
import StyledInput from '../../base/StyledInput.vue' import StyledInput from '../../base/StyledInput.vue'
import NewModal from '../../modal/NewModal.vue' import NewModal from '../../modal/NewModal.vue'
import type { InviteLinkSettings } from './types' import type { InviteLinkSettings } from './types'
const EXPIRY_PRESET_DURATIONS = {
one_hour: 3_600_000,
six_hours: 6 * 3_600_000,
twelve_hours: 12 * 3_600_000,
one_day: 86_400_000,
three_days: 3 * 86_400_000,
seven_days: 7 * 86_400_000,
} as const
const MINIMUM_EXPIRY_DURATION = EXPIRY_PRESET_DURATIONS.one_hour
const MAXIMUM_EXPIRY_DURATION = EXPIRY_PRESET_DURATIONS.seven_days
const EXPIRY_PRESET_MATCH_TOLERANCE = 2 * 60_000
type ExpiryPreset = keyof typeof EXPIRY_PRESET_DURATIONS
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
linkExpiresAt?: string | Date | null linkExpiresAt?: string | Date | null
@@ -74,11 +134,17 @@ const { formatMessage } = useVIntl()
const notificationManager = injectNotificationManager(null) const notificationManager = injectNotificationManager(null)
const modal = ref<InstanceType<typeof NewModal> | null>(null) const modal = ref<InstanceType<typeof NewModal> | null>(null)
const expiry = ref('') const expiry = ref('')
const expiryMode = ref<'preset' | 'custom'>('preset')
const expiryPreset = ref<ExpiryPreset>('seven_days')
const expiryReferenceTime = ref(Date.now())
const customExpiry = ref('')
const customExpiryOpen = ref(false)
const maxUses = ref<number>() const maxUses = ref<number>()
const minimumExpiry = ref(new Date()) const minimumExpiry = ref(new Date())
const maximumExpiry = ref(new Date()) const maximumExpiry = ref(new Date())
const saving = ref(false) const saving = ref(false)
const maximumUses = computed(() => Math.max(0, Math.floor(props.linkMaxUsesLimit))) const maximumUses = computed(() => Math.max(0, Math.floor(props.linkMaxUsesLimit)))
const formatExpiryDate = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
const messages = defineMessages({ const messages = defineMessages({
title: { title: {
@@ -93,10 +159,46 @@ const messages = defineMessages({
id: 'sharing.invite-players-modal.max-uses-label', id: 'sharing.invite-players-modal.max-uses-label',
defaultMessage: 'Maximum uses', defaultMessage: 'Maximum uses',
}, },
inOneHour: {
id: 'sharing.invite-players-modal.expiry-in-one-hour',
defaultMessage: 'In 1 hour',
},
inSixHours: {
id: 'sharing.invite-players-modal.expiry-in-six-hours',
defaultMessage: 'In 6 hours',
},
inTwelveHours: {
id: 'sharing.invite-players-modal.expiry-in-twelve-hours',
defaultMessage: 'In 12 hours',
},
inOneDay: {
id: 'sharing.invite-players-modal.expiry-in-one-day',
defaultMessage: 'In 1 day',
},
inThreeDays: {
id: 'sharing.invite-players-modal.expiry-in-three-days',
defaultMessage: 'In 3 days',
},
inSevenDays: {
id: 'sharing.invite-players-modal.expiry-in-seven-days',
defaultMessage: 'In 7 days',
},
customExpiry: {
id: 'sharing.invite-players-modal.custom-expiry',
defaultMessage: 'Custom...',
},
customExpiryValue: {
id: 'sharing.invite-players-modal.custom-expiry-value',
defaultMessage: 'Custom: {date}',
},
cancel: { cancel: {
id: 'sharing.invite-players-modal.cancel-button', id: 'sharing.invite-players-modal.cancel-button',
defaultMessage: 'Cancel', defaultMessage: 'Cancel',
}, },
apply: {
id: 'sharing.invite-players-modal.apply-button',
defaultMessage: 'Apply',
},
save: { save: {
id: 'sharing.invite-players-modal.save-button', id: 'sharing.invite-players-modal.save-button',
defaultMessage: 'Save', defaultMessage: 'Save',
@@ -107,6 +209,35 @@ const messages = defineMessages({
}, },
}) })
const expiryOptions = computed<ComboboxOption<ExpiryPreset>[]>(() => [
{ value: 'one_hour', label: formatMessage(messages.inOneHour) },
{ value: 'six_hours', label: formatMessage(messages.inSixHours) },
{ value: 'twelve_hours', label: formatMessage(messages.inTwelveHours) },
{ value: 'one_day', label: formatMessage(messages.inOneDay) },
{ value: 'three_days', label: formatMessage(messages.inThreeDays) },
{ value: 'seven_days', label: formatMessage(messages.inSevenDays) },
])
const expiryDropdownOptions = computed(() => (customExpiryOpen.value ? [] : expiryOptions.value))
const selectedExpiryPreset = computed(() =>
expiryMode.value === 'preset' ? expiryPreset.value : undefined,
)
const expiryPickerLabel = computed(() => {
if (expiryMode.value === 'preset') {
return (
expiryOptions.value.find((option) => option.value === expiryPreset.value)?.label ??
formatMessage(messages.inSevenDays)
)
}
const date = parseLocalDate(expiry.value)
return date
? formatMessage(messages.customExpiryValue, { date: formatExpiryDate(date) })
: formatMessage(messages.customExpiry)
})
const canApplyCustomExpiry = computed(() => {
const date = parseLocalDate(customExpiry.value)
return !!date && date >= minimumExpiry.value && date <= maximumExpiry.value
})
const canSave = computed(() => { const canSave = computed(() => {
const date = parseLocalDate(expiry.value) const date = parseLocalDate(expiry.value)
return ( return (
@@ -133,13 +264,48 @@ function parseLocalDate(value: string) {
return Number.isNaN(date.getTime()) ? null : date return Number.isNaN(date.getTime()) ? null : date
} }
function roundDownToMinute(timestamp: number) {
const date = new Date(timestamp)
date.setSeconds(0, 0)
return date
}
function roundUpToMinute(timestamp: number) {
const date = roundDownToMinute(timestamp)
if (date.getTime() < timestamp) date.setMinutes(date.getMinutes() + 1)
return date
}
function expiryForPreset(preset: ExpiryPreset) {
const expiryTimestamp = expiryReferenceTime.value + EXPIRY_PRESET_DURATIONS[preset]
const date = roundDownToMinute(expiryTimestamp)
if (date < minimumExpiry.value) return minimumExpiry.value
if (date > maximumExpiry.value) return maximumExpiry.value
return date
}
function matchingExpiryPreset(date: Date) {
const duration = date.getTime() - expiryReferenceTime.value
let closestPreset: ExpiryPreset | null = null
let closestDifference = Number.POSITIVE_INFINITY
for (const [preset, presetDuration] of Object.entries(EXPIRY_PRESET_DURATIONS) as Array<
[ExpiryPreset, number]
>) {
const difference = Math.abs(duration - presetDuration)
if (difference < closestDifference) {
closestPreset = preset
closestDifference = difference
}
}
return closestDifference <= EXPIRY_PRESET_MATCH_TOLERANCE ? closestPreset : null
}
function show() { function show() {
const now = new Date() expiryReferenceTime.value = Date.now()
minimumExpiry.value = new Date(now.getTime() + 3_600_000) minimumExpiry.value = roundUpToMinute(expiryReferenceTime.value + MINIMUM_EXPIRY_DURATION)
minimumExpiry.value.setSeconds(0, 0) maximumExpiry.value = roundDownToMinute(expiryReferenceTime.value + MAXIMUM_EXPIRY_DURATION)
minimumExpiry.value.setMinutes(minimumExpiry.value.getMinutes() + 1)
maximumExpiry.value = new Date(now.getTime() + 7 * 86_400_000)
maximumExpiry.value.setSeconds(0, 0)
const currentExpiry = props.linkExpiresAt ? new Date(props.linkExpiresAt) : maximumExpiry.value const currentExpiry = props.linkExpiresAt ? new Date(props.linkExpiresAt) : maximumExpiry.value
const date = const date =
Number.isNaN(currentExpiry.getTime()) || currentExpiry < minimumExpiry.value Number.isNaN(currentExpiry.getTime()) || currentExpiry < minimumExpiry.value
@@ -148,10 +314,56 @@ function show() {
? maximumExpiry.value ? maximumExpiry.value
: currentExpiry : currentExpiry
expiry.value = formatLocalDate(date) expiry.value = formatLocalDate(date)
const matchingPreset = matchingExpiryPreset(date)
expiryMode.value = matchingPreset ? 'preset' : 'custom'
if (matchingPreset) expiryPreset.value = matchingPreset
customExpiry.value = expiry.value
customExpiryOpen.value = false
maxUses.value = Math.min(props.linkMaxUses, maximumUses.value) maxUses.value = Math.min(props.linkMaxUses, maximumUses.value)
modal.value?.show() modal.value?.show()
} }
function selectExpiryPreset(option: ComboboxOption<ExpiryPreset>) {
expiryMode.value = 'preset'
expiryPreset.value = option.value
expiry.value = formatLocalDate(expiryForPreset(option.value))
}
function handleExpiryPickerOpen() {
customExpiryOpen.value = false
}
function handleExpiryPickerClose() {
customExpiryOpen.value = false
customExpiry.value = expiry.value
}
function openCustomExpiry() {
customExpiry.value = expiry.value
customExpiryOpen.value = true
}
function cancelCustomExpiry() {
customExpiry.value = expiry.value
customExpiryOpen.value = false
}
function closeExpiryPicker(event: Event) {
const target = event.target
if (!(target instanceof HTMLElement)) return
target
.closest('[role="listbox"], [role="menu"]')
?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
}
function applyCustomExpiry(event: MouseEvent) {
const date = parseLocalDate(customExpiry.value)
if (!canApplyCustomExpiry.value || !date) return
expiryMode.value = 'custom'
expiry.value = formatLocalDate(date)
closeExpiryPicker(event)
}
async function save() { async function save() {
const date = parseLocalDate(expiry.value) const date = parseLocalDate(expiry.value)
if (!canSave.value || !date || !props.updateInviteLink) return if (!canSave.value || !date || !props.updateInviteLink) return