Skip to content

Commit

Permalink
feat: customize start/end time for offchain proposals (#1015)
Browse files Browse the repository at this point in the history
* refactor: move the proposal start and end time to Editor

* fix: update Editor

* fix: fix empty voting period being overriden

* feat: add button to edit proposal start and end time

* fix: enable date edition only on proposal creation

* fix: fix empty voting period being overriden

* fix: update proposal start and end time from datetime modal

* feat: add calendar component

* fix: set a default voting period when not set by the space

* feat: add time selector to datetime modal

* fix: validate time

* feat: Show message when proposal start/end time are not editable

* fix: fix error message when min time is not current time

* fix: prevent invalid proposal dates

* fix: fix current minute not considered as valid minimum

* fix: reset ref on modal opening

* fix: move all reset logic inside watcher

* fix: improve datetime selection

* refactor: extract editor's timeline into its own component

* fix: use single source of truth for current time

* fix: use custom proposal time only for offchain spaces

* fix: fix merge conflict

* fix: remove times normalization, since NOW is now fixed

* fix: fix missing start date

* fix: slide the end date to the future when start date is greater than end date

* fix: fix background on cell from not relevant months

* fix: show correct timeline from draft

* feat: save proposal time in draft

* fix: add max_voting_period to draft

* fix: use dynamix current timestamp

* fix: make timestamp reactive

* fix: make timestamp reactive

* fix: disable max_voting_period for offchain spaces

* refactor: improve dateTime component

* fix: compare dates rounded to the nearest minute

* fix: clear trailing seconds

* perf: improve calendar

* fix: improve EditorTimeline

* refactor: improve naming

* fix: fix invalid computed property reading

* fix: do not use fallback voting period for onchain spaces

* fix: fix editor datetime modal missing teleportation

* refactor: use draft directly to store custom proposal start and end date

* style: silence eslint error
  • Loading branch information
wa0x6e authored Dec 10, 2024
1 parent d9f9ada commit 96d9bfa
Show file tree
Hide file tree
Showing 7 changed files with 469 additions and 23 deletions.
151 changes: 151 additions & 0 deletions apps/ui/src/components/EditorTimeline.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<script setup lang="ts">
import { _d } from '@/helpers/utils';
import { offchainNetworks } from '@/networks';
import { Draft, Space } from '@/types';
type EditModalSettings = {
open: boolean;
editProperty: 'start' | 'min_end';
min?: number;
selected: number;
};
const MIN_VOTING_PERIOD = 60;
const proposal = defineModel<Draft>({
required: true
});
const props = defineProps<{
space: Space;
editable: boolean;
created: number;
start: number;
// eslint-disable-next-line vue/prop-name-casing
min_end: number;
// eslint-disable-next-line vue/prop-name-casing
max_end: number;
}>();
const { getDurationFromCurrent } = useMetaStore();
const editModalSettings = reactive<EditModalSettings>({
open: false,
editProperty: 'start',
selected: 0
});
const isOffchainSpace = computed(() =>
offchainNetworks.includes(props.space.network)
);
const minDates = computed(() => {
return {
start: props.created,
min_end: props.start + MIN_VOTING_PERIOD
};
});
function handleEditClick(type: 'start' | 'min_end') {
editModalSettings.selected = props[type];
editModalSettings.min = minDates.value[type];
editModalSettings.editProperty = type;
editModalSettings.open = true;
}
function handleDatePick(timestamp: number) {
if (
editModalSettings.editProperty === 'start' &&
proposal.value.min_end &&
timestamp >= proposal.value.min_end
) {
const customVotingPeriod = props.min_end - props.start;
proposal.value.min_end = timestamp + customVotingPeriod;
}
proposal.value[editModalSettings.editProperty] = timestamp;
}
function formatVotingDuration(
type: 'voting_delay' | 'min_voting_period' | 'max_voting_period'
): string {
const duration = getDurationFromCurrent(
props.space.network,
props.space[type]
);
const roundedDuration = Math.round(duration / 60) * 60;
return _d(roundedDuration);
}
</script>

<template>
<div>
<h4 class="eyebrow mb-2.5" v-text="'Timeline'" />
<ProposalTimeline
:data="
isOffchainSpace || !editable
? {
...space,
created,
start,
min_end,
max_end
}
: space
"
>
<template v-if="editable" #start-date-suffix>
<UiTooltip
v-if="space.voting_delay"
:title="`This space has enforced a ${formatVotingDuration('voting_delay')} voting delay`"
>
<IH-exclamation-circle />
</UiTooltip>
<button
v-else-if="isOffchainSpace"
class="text-skin-link"
@click="handleEditClick('start')"
v-text="'Edit'"
/>
</template>
<template v-if="editable" #end-date-suffix>
<UiTooltip
v-if="space.min_voting_period"
:title="`This space has enforced a ${formatVotingDuration('min_voting_period')} voting period`"
>
<IH-exclamation-circle />
</UiTooltip>
<button
v-else-if="isOffchainSpace"
class="text-skin-link"
@click="handleEditClick('min_end')"
v-text="'Edit'"
/>
</template>
<template v-if="editable && space.min_voting_period" #min_end-date-suffix>
<UiTooltip
:title="`This space has enforced a ${formatVotingDuration('min_voting_period')} minimum voting period`"
>
<IH-exclamation-circle />
</UiTooltip>
</template>
<template v-if="editable && space.max_voting_period" #max_end-date-suffix>
<UiTooltip
:title="`This space has enforced a ${formatVotingDuration('max_voting_period')} maximum voting period`"
>
<IH-exclamation-circle />
</UiTooltip>
</template>
</ProposalTimeline>
<teleport to="#modal">
<ModalDateTime
:min="editModalSettings.min"
:selected="editModalSettings.selected"
:open="editModalSettings.open"
@pick="handleDatePick"
@close="editModalSettings.open = false"
/>
</teleport>
</div>
</template>
151 changes: 151 additions & 0 deletions apps/ui/src/components/Modal/DateTime.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<script setup lang="ts">
import dayjs from 'dayjs';
const TIME_FORMAT = 'HH:mm';
const STEPS = {
date: { id: 'date', title: 'Select date' },
time: { id: 'time', title: 'Select time' }
};
const props = defineProps<{
open: boolean;
min?: number;
selected: number;
}>();
const emit = defineEmits<{
(e: 'close'): void;
(e: 'pick', timestamp: number): void;
}>();
const { current, isCurrent, goTo, goToNext, goToPrevious, isFirst, isLast } =
useStepper(STEPS);
const date = ref<number>(props.selected);
const time = ref<string>(getBaseTime(props.selected));
const formError = ref<null | string>(null);
function handleClose() {
emit('close');
}
function handleSubmit() {
handleClose();
emit('pick', date.value);
}
function handleDateUpdate(ts: number) {
date.value = ts;
time.value = getBaseTime(ts);
goToNext();
}
function getBaseTime(ts: number): string {
const originalDate = dayjs.unix(props.selected);
const selectedDate = dayjs
.unix(ts)
.set('hour', originalDate.get('hour'))
.set('minute', originalDate.get('minute'));
if (props.min) {
const minDate = dayjs.unix(props.min);
if (selectedDate.isBefore(minDate, 'minute')) {
return minDate.format(TIME_FORMAT);
}
}
return selectedDate.format(TIME_FORMAT);
}
function updateDateWithTime() {
const [hours, minutes] = time.value.split(':');
date.value = dayjs
.unix(date.value)
.set('hour', +hours)
.set('minute', +minutes)
.startOf('minute')
.unix();
}
function validateForm() {
if (!props.min) return;
const minDate = dayjs.unix(props.min).startOf('minute');
if (date.value < minDate.unix()) {
formError.value = `Time must be equal or greater than ${minDate.format(TIME_FORMAT)}`;
return;
}
formError.value = null;
}
watch([() => current.value.id, time], ([stepId]) => {
if (stepId !== 'time') return;
updateDateWithTime();
validateForm();
});
watch(
() => props.open,
open => {
if (open) {
goTo('date');
date.value = props.selected;
time.value = getBaseTime(props.selected);
}
}
);
</script>

<template>
<UiModal :open="open" @close="handleClose">
<template #header>
<h3 v-text="current.title" />
</template>
<div :class="['!m-4 text-center', { 's-error': formError }]">
<UiCalendar
v-if="isCurrent('date')"
:min="min"
:selected="date"
@pick="handleDateUpdate"
/>
<template v-else-if="isCurrent('time')">
<input
v-model="time"
type="time"
class="s-input mx-auto max-w-[140px] text-center text-lg"
/>
<span
v-if="formError"
class="s-input-error-message"
v-text="formError"
/>
</template>
</div>
<template #footer>
<div class="flex space-x-3">
<UiButton v-if="isFirst" class="w-full" @click="handleClose">
Cancel
</UiButton>
<UiButton v-else class="w-full" @click="goToPrevious">
Previous
</UiButton>
<UiButton v-if="!isLast" class="primary w-full" @click="goToNext">
Next
</UiButton>
<UiButton
v-else
class="primary w-full"
:disabled="!!formError"
@click="handleSubmit"
>
Confirm
</UiButton>
</div>
</template>
</UiModal>
</template>
5 changes: 4 additions & 1 deletion apps/ui/src/components/ProposalTimeline.vue
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,10 @@ function isInThePast(timestamp: number): boolean {
class="mb-3 last:mb-0 h-[44px]"
>
<h4 v-text="LABELS[state.id]" />
{{ _t(state.value) }}
<div class="flex gap-2 items-center">
<div v-text="_t(state.value)" />
<slot :name="`${state.id}-date-suffix`" />
</div>
</div>
</div>
</div>
Expand Down
Loading

0 comments on commit 96d9bfa

Please sign in to comment.