Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b1e8d3c27 | ||
|
|
598ece9a2d | ||
|
|
059506b1db | ||
|
|
397b0bcc9d | ||
|
|
fd69b4c8f2 | ||
|
|
3ea5f258a4 | ||
|
|
42a244369d | ||
|
|
3abe32a2c7 | ||
|
|
f24e7eb231 | ||
|
|
8cfbb75128 | ||
|
|
a1b98a253c | ||
|
|
374d2258c7 | ||
|
|
89da4a2292 | ||
|
|
9aacc0335b |
@@ -68,6 +68,15 @@
|
||||
- Example: `feat(auth): add user authentication`
|
||||
- Don't reference Claude in commit messages
|
||||
|
||||
## PR Description Format
|
||||
|
||||
- Start with a short, user-facing paragraph describing the product change.
|
||||
- Add a `Closes` section with relevant issue links (GitHub, Linear, etc.).
|
||||
- For feature PRs, add `How to test` from a product/UX standpoint.
|
||||
- For bugfix PRs, use `How to reproduce` when helpful.
|
||||
- Optionally add a `What changed` section for implementation highlights.
|
||||
- Do not add a `How this was tested` section listing specs/commands.
|
||||
|
||||
## Project-Specific
|
||||
|
||||
- **Translations**:
|
||||
|
||||
@@ -40,8 +40,12 @@ run:
|
||||
fi
|
||||
|
||||
force_run:
|
||||
rm -f ./.overmind.sock
|
||||
rm -f tmp/pids/*.pid
|
||||
@echo "Cleaning up Overmind processes..."
|
||||
@lsof -ti:3036 2>/dev/null | xargs kill -9 2>/dev/null || true
|
||||
@lsof -ti:3000 2>/dev/null | xargs kill -9 2>/dev/null || true
|
||||
@rm -f ./.overmind.sock
|
||||
@rm -f tmp/pids/*.pid
|
||||
@echo "Cleanup complete"
|
||||
overmind start -f Procfile.dev
|
||||
|
||||
force_run_tunnel:
|
||||
|
||||
@@ -2,12 +2,17 @@ class Messages::Messenger::MessageBuilder
|
||||
include ::FileTypeHelper
|
||||
|
||||
def process_attachment(attachment)
|
||||
# This check handles very rare case if there are multiple files to attach with only one usupported file
|
||||
# This check handles very rare case if there are multiple files to attach with only one unsupported file
|
||||
return if unsupported_file_type?(attachment['type'])
|
||||
|
||||
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
|
||||
params = attachment_params(attachment)
|
||||
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
|
||||
attachment_obj.save!
|
||||
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
||||
if facebook_reel?(attachment)
|
||||
update_facebook_reel_content(attachment)
|
||||
elsif params[:remote_file_url]
|
||||
attach_file(attachment_obj, params[:remote_file_url])
|
||||
end
|
||||
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
||||
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
|
||||
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
|
||||
@@ -26,7 +31,7 @@ class Messages::Messenger::MessageBuilder
|
||||
end
|
||||
|
||||
def attachment_params(attachment)
|
||||
file_type = attachment['type'].to_sym
|
||||
file_type = normalize_file_type(attachment['type'])
|
||||
params = { file_type: file_type, account_id: @message.account_id }
|
||||
|
||||
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
|
||||
@@ -100,6 +105,28 @@ class Messages::Messenger::MessageBuilder
|
||||
|
||||
private
|
||||
|
||||
# Facebook may send attachment types that don't directly match our file_type enum.
|
||||
# Map known aliases to their canonical enum values.
|
||||
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze
|
||||
|
||||
def normalize_file_type(type)
|
||||
sym = type.to_sym
|
||||
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
|
||||
end
|
||||
|
||||
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
|
||||
# direct video URLs. Downloading these yields HTML, not video content.
|
||||
def facebook_reel?(attachment)
|
||||
attachment['type'].to_sym == :reel
|
||||
end
|
||||
|
||||
def update_facebook_reel_content(attachment)
|
||||
url = attachment.dig('payload', 'url')
|
||||
return if url.blank?
|
||||
|
||||
@message.update!(content: url) if @message.content.blank?
|
||||
end
|
||||
|
||||
def unsupported_file_type?(attachment_type)
|
||||
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
|
||||
end
|
||||
|
||||
@@ -40,7 +40,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def reorder
|
||||
Article.update_positions(params[:positions_hash])
|
||||
Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
|
||||
before_action :portal
|
||||
before_action :check_authorization
|
||||
before_action :fetch_category, except: [:index, :create]
|
||||
before_action :fetch_category, except: [:index, :create, :reorder]
|
||||
before_action :set_current_page, only: [:index]
|
||||
|
||||
def index
|
||||
@@ -32,6 +32,11 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
|
||||
head :ok
|
||||
end
|
||||
|
||||
def reorder
|
||||
Category.update_positions(portal: @portal, positions_hash: params[:positions_hash])
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_category
|
||||
@@ -39,7 +44,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
|
||||
end
|
||||
|
||||
def portal
|
||||
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
|
||||
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
|
||||
end
|
||||
|
||||
def related_categories_records
|
||||
|
||||
@@ -107,7 +107,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def toggle_typing_status
|
||||
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, current_user, params)
|
||||
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, Current.user, params)
|
||||
typing_status_manager.toggle_typing_status
|
||||
head :ok
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module AccessTokenAuthHelper
|
||||
BOT_ACCESSIBLE_ENDPOINTS = {
|
||||
'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create update custom_attributes],
|
||||
'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes],
|
||||
'api/v1/accounts/conversations/messages' => ['create'],
|
||||
'api/v1/accounts/conversations/assignments' => ['create']
|
||||
}.freeze
|
||||
@@ -28,7 +28,7 @@ module AccessTokenAuthHelper
|
||||
|
||||
def validate_bot_access_token!
|
||||
return if Current.user.is_a?(User)
|
||||
return if agent_bot_accessible?
|
||||
return if @resource.is_a?(AgentBot) && agent_bot_accessible?
|
||||
|
||||
render_unauthorized('Access to this endpoint is not authorized for bots')
|
||||
end
|
||||
|
||||
@@ -57,14 +57,14 @@ class ContactAPI extends ApiClient {
|
||||
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
||||
}
|
||||
|
||||
search(search = '', page = 1, sortAttr = 'name', label = '') {
|
||||
search(search = '', page = 1, sortAttr = 'name', label = '', options = {}) {
|
||||
let requestURL = `${this.url}/search?${buildContactParams(
|
||||
page,
|
||||
sortAttr,
|
||||
label,
|
||||
search
|
||||
)}`;
|
||||
return axios.get(requestURL);
|
||||
return axios.get(requestURL, { signal: options.signal });
|
||||
}
|
||||
|
||||
active(page = 1, sortAttr = 'name') {
|
||||
|
||||
@@ -25,6 +25,12 @@ class CategoriesAPI extends PortalsAPI {
|
||||
delete({ portalSlug, categoryId }) {
|
||||
return axios.delete(`${this.url}/${portalSlug}/categories/${categoryId}`);
|
||||
}
|
||||
|
||||
reorder({ portalSlug, reorderedGroup }) {
|
||||
return axios.post(`${this.url}/${portalSlug}/categories/reorder`, {
|
||||
positions_hash: reorderedGroup,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CategoriesAPI();
|
||||
|
||||
@@ -68,7 +68,19 @@ describe('#ContactsAPI', () => {
|
||||
it('#search', () => {
|
||||
contactAPI.search('leads', 1, 'date', 'customer-support');
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support'
|
||||
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support',
|
||||
{ signal: undefined }
|
||||
);
|
||||
});
|
||||
|
||||
it('#search with signal', () => {
|
||||
const controller = new AbortController();
|
||||
contactAPI.search('leads', 1, 'date', 'customer-support', {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support',
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,5 +8,6 @@ describe('#BulkActionsAPI', () => {
|
||||
expect(categoriesAPI).toHaveProperty('create');
|
||||
expect(categoriesAPI).toHaveProperty('update');
|
||||
expect(categoriesAPI).toHaveProperty('delete');
|
||||
expect(categoriesAPI).toHaveProperty('reorder');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
// Base styles for elements
|
||||
@import 'base';
|
||||
|
||||
// Plugins
|
||||
@import 'plugins/date-picker';
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family:
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import {
|
||||
startOfMonth,
|
||||
addMonths,
|
||||
subMonths,
|
||||
startOfDay,
|
||||
isSameDay,
|
||||
addHours,
|
||||
addYears,
|
||||
subYears,
|
||||
setMonth,
|
||||
setYear,
|
||||
} from 'date-fns';
|
||||
import { CALENDAR_PERIODS } from './helpers/DatePickerHelper';
|
||||
import CalendarYear from './components/CalendarYear.vue';
|
||||
import CalendarMonth from './components/CalendarMonth.vue';
|
||||
import CalendarWeek from './components/CalendarWeek.vue';
|
||||
import CalendarFooter from './components/CalendarFooter.vue';
|
||||
import TimePicker from './components/TimePicker.vue';
|
||||
|
||||
const props = defineProps({
|
||||
minDate: {
|
||||
type: Date,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['apply', 'clear']);
|
||||
|
||||
const { WEEK, MONTH, YEAR } = CALENDAR_PERIODS;
|
||||
|
||||
const currentDate = ref(new Date());
|
||||
const selectedDate = ref(null);
|
||||
|
||||
const getCurrentTime = () => {
|
||||
const now = new Date();
|
||||
return {
|
||||
hour: now.getHours(),
|
||||
minute: now.getMinutes(),
|
||||
second: now.getSeconds(),
|
||||
};
|
||||
};
|
||||
|
||||
const selectedTime = ref(getCurrentTime());
|
||||
const calendarView = ref(WEEK);
|
||||
const calendarDate = ref(startOfMonth(currentDate.value));
|
||||
|
||||
const getMinTimeForToday = () => addHours(new Date(), 1);
|
||||
|
||||
const effectiveMinDate = computed(() => {
|
||||
const thresholdDay = startOfDay(getMinTimeForToday());
|
||||
if (!props.minDate) return thresholdDay;
|
||||
const propDay = startOfDay(props.minDate);
|
||||
return propDay > thresholdDay ? propDay : thresholdDay;
|
||||
});
|
||||
|
||||
const minTime = computed(() => {
|
||||
if (!selectedDate.value) return null;
|
||||
if (!isSameDay(selectedDate.value, effectiveMinDate.value)) return null;
|
||||
return getMinTimeForToday();
|
||||
});
|
||||
|
||||
const selectedDateTime = computed(() => {
|
||||
if (!selectedDate.value) return null;
|
||||
const date = new Date(selectedDate.value);
|
||||
date.setHours(
|
||||
selectedTime.value.hour,
|
||||
selectedTime.value.minute,
|
||||
selectedTime.value.second,
|
||||
0
|
||||
);
|
||||
return date;
|
||||
});
|
||||
|
||||
const selectDate = day => {
|
||||
selectedDate.value = startOfDay(day);
|
||||
if (isSameDay(day, effectiveMinDate.value)) {
|
||||
const threshold = getMinTimeForToday();
|
||||
selectedTime.value = {
|
||||
hour: threshold.getHours(),
|
||||
minute: threshold.getMinutes(),
|
||||
second: 0,
|
||||
};
|
||||
} else {
|
||||
selectedTime.value = { hour: 0, minute: 0, second: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
const moveCalendar = (direction, period = MONTH) => {
|
||||
const adjust =
|
||||
period === YEAR
|
||||
? { prev: subYears, next: addYears }
|
||||
: { prev: subMonths, next: addMonths };
|
||||
calendarDate.value = adjust[direction](calendarDate.value, 1);
|
||||
};
|
||||
|
||||
const setViewMode = (_type, mode) => {
|
||||
calendarView.value = mode;
|
||||
};
|
||||
|
||||
const openCalendar = (index, _type, period = MONTH) => {
|
||||
calendarDate.value =
|
||||
period === MONTH
|
||||
? setMonth(startOfMonth(calendarDate.value), index)
|
||||
: setYear(calendarDate.value, index);
|
||||
calendarView.value = period === MONTH ? WEEK : MONTH;
|
||||
};
|
||||
|
||||
const onApply = () => {
|
||||
if (selectedDateTime.value) {
|
||||
emit('apply', selectedDateTime.value);
|
||||
}
|
||||
};
|
||||
|
||||
const isDefaultState = computed(
|
||||
() => !selectedDate.value && calendarView.value === WEEK
|
||||
);
|
||||
|
||||
const resetState = () => {
|
||||
selectedDate.value = null;
|
||||
selectedTime.value = getCurrentTime();
|
||||
calendarDate.value = startOfMonth(currentDate.value);
|
||||
calendarView.value = WEEK;
|
||||
};
|
||||
|
||||
const onClear = () => {
|
||||
if (isDefaultState.value) {
|
||||
emit('clear');
|
||||
return;
|
||||
}
|
||||
resetState();
|
||||
};
|
||||
|
||||
defineExpose({ resetState });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col select-none font-inter">
|
||||
<div class="flex w-full gap-3 justify-between">
|
||||
<div class="flex justify-center py-5">
|
||||
<div class="flex flex-col items-center gap-2 min-w-[300px]">
|
||||
<CalendarYear
|
||||
v-if="calendarView === YEAR"
|
||||
:start-current-date="calendarDate"
|
||||
@select-year="openCalendar($event, null, YEAR)"
|
||||
/>
|
||||
<CalendarMonth
|
||||
v-else-if="calendarView === MONTH"
|
||||
:start-current-date="calendarDate"
|
||||
@select-month="openCalendar($event)"
|
||||
@set-view="setViewMode"
|
||||
@prev="moveCalendar('prev', YEAR)"
|
||||
@next="moveCalendar('next', YEAR)"
|
||||
/>
|
||||
<CalendarWeek
|
||||
v-else
|
||||
:current-date="currentDate"
|
||||
:start-current-date="calendarDate"
|
||||
:selected-start-date="selectedDate"
|
||||
:selected-end-date="selectedDate"
|
||||
:min-date="effectiveMinDate"
|
||||
@select-date="selectDate"
|
||||
@set-view="setViewMode"
|
||||
@prev="moveCalendar('prev')"
|
||||
@next="moveCalendar('next')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-center py-2 w-full transition-opacity ltr:border-l rtl:border-r border-n-strong"
|
||||
:class="selectedDate ? 'opacity-100' : 'opacity-40 pointer-events-none'"
|
||||
>
|
||||
<TimePicker v-model="selectedTime" :min-time="minTime" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-n-strong">
|
||||
<CalendarFooter @change="onApply" @clear="onClear" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,430 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import {
|
||||
TIME_COLUMNS,
|
||||
TIME_PERIODS,
|
||||
TIME_FORMATS,
|
||||
} from '../helpers/DatePickerHelper';
|
||||
|
||||
const props = defineProps({
|
||||
minTime: { type: Date, default: null },
|
||||
});
|
||||
|
||||
const model = defineModel({
|
||||
type: Object,
|
||||
default: () => ({ hour: 9, minute: 0, second: 0 }),
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { HOUR, HOUR_12, MINUTE, SECOND, PERIOD } = TIME_COLUMNS;
|
||||
const { AM, PM } = TIME_PERIODS;
|
||||
const { H24, H12 } = TIME_FORMATS;
|
||||
|
||||
const ITEM_HEIGHT = 40;
|
||||
const CENTER_ROW = 2;
|
||||
const VISIBLE_ROWS = 5;
|
||||
const SNAP_DELAY = 120;
|
||||
const SEPARATOR = ':';
|
||||
|
||||
const is24HourFormat = ref(false);
|
||||
const selectedTime = reactive({
|
||||
hour: model.value.hour,
|
||||
minute: model.value.minute,
|
||||
second: model.value.second ?? 0,
|
||||
});
|
||||
const activeIndex = reactive({});
|
||||
const dragOffset = reactive({});
|
||||
const interactionMode = reactive({});
|
||||
const touchStartY = {};
|
||||
const snapTimers = {};
|
||||
const columnRefs = reactive({});
|
||||
const focusedColumn = ref(null);
|
||||
|
||||
const period = computed(() => (selectedTime.hour >= 12 ? PM : AM));
|
||||
const showPeriod = computed(() => !is24HourFormat.value);
|
||||
|
||||
const minBound = computed(() => {
|
||||
if (!props.minTime) return null;
|
||||
const d = props.minTime;
|
||||
return { h: d.getHours(), m: d.getMinutes(), s: d.getSeconds() };
|
||||
});
|
||||
|
||||
const toTimeNumber = (h, m, s) => h * 3600 + m * 60 + s;
|
||||
|
||||
const convertTo12Hour = hour => {
|
||||
if (hour === 0) return 12;
|
||||
return hour > 12 ? hour - 12 : hour;
|
||||
};
|
||||
|
||||
const convertTo24Hour = (hour12, timePeriod) => {
|
||||
if (timePeriod === AM) return hour12 === 12 ? 0 : hour12;
|
||||
return hour12 === 12 ? 12 : hour12 + 12;
|
||||
};
|
||||
|
||||
const isItemDisabled = (key, val) => {
|
||||
const mb = minBound.value;
|
||||
if (!mb) return false;
|
||||
const minVal = toTimeNumber(mb.h, mb.m, mb.s);
|
||||
const { hour, minute } = selectedTime;
|
||||
if (key === HOUR) return toTimeNumber(val, 0, 0) < toTimeNumber(mb.h, 0, 0);
|
||||
if (key === MINUTE) return toTimeNumber(hour, val, 0) < minVal;
|
||||
if (key === SECOND) return toTimeNumber(hour, minute, val) < minVal;
|
||||
return false;
|
||||
};
|
||||
|
||||
const formatLabel = value => String(value).padStart(2, '0');
|
||||
|
||||
const buildItems = (count, key) =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
value: i,
|
||||
label: formatLabel(i),
|
||||
disabled: isItemDisabled(key, i),
|
||||
}));
|
||||
|
||||
const buildHour12Items = () =>
|
||||
Array.from({ length: 12 }, (_, i) => {
|
||||
const display = i === 0 ? 12 : i;
|
||||
return {
|
||||
value: display,
|
||||
label: formatLabel(display),
|
||||
disabled: isItemDisabled(HOUR, convertTo24Hour(display, period.value)),
|
||||
};
|
||||
});
|
||||
|
||||
const periodItems = computed(() => [
|
||||
{ value: AM, label: AM, disabled: minBound.value?.h >= 12 },
|
||||
{ value: PM, label: PM, disabled: false },
|
||||
]);
|
||||
|
||||
const columns = computed(() => [
|
||||
{
|
||||
key: is24HourFormat.value ? HOUR : HOUR_12,
|
||||
label: t('DATE_PICKER.HOUR'),
|
||||
items: is24HourFormat.value ? buildItems(24, HOUR) : buildHour12Items(),
|
||||
},
|
||||
{
|
||||
key: MINUTE,
|
||||
label: t('DATE_PICKER.MINUTE'),
|
||||
items: buildItems(60, MINUTE),
|
||||
},
|
||||
{
|
||||
key: SECOND,
|
||||
label: t('DATE_PICKER.SECOND'),
|
||||
items: buildItems(60, SECOND),
|
||||
},
|
||||
{ key: PERIOD, label: '', items: periodItems.value },
|
||||
]);
|
||||
|
||||
const findColumn = key => columns.value.find(c => c.key === key);
|
||||
|
||||
const findNearestEnabled = (items, index) => {
|
||||
for (let d = 0; d < items.length; d += 1) {
|
||||
if (index + d < items.length && !items[index + d].disabled)
|
||||
return index + d;
|
||||
if (index - d >= 0 && !items[index - d].disabled) return index - d;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
const getActiveIndex = key => {
|
||||
if (key === HOUR) return selectedTime.hour;
|
||||
if (key === HOUR_12) {
|
||||
const h = convertTo12Hour(selectedTime.hour);
|
||||
return h === 12 ? 0 : h;
|
||||
}
|
||||
if (key === PERIOD) return period.value === AM ? 0 : 1;
|
||||
return selectedTime[key];
|
||||
};
|
||||
|
||||
const getColumnTranslateY = key => {
|
||||
const idx = -(activeIndex[key] ?? 0) * ITEM_HEIGHT;
|
||||
return CENTER_ROW * ITEM_HEIGHT + idx + (dragOffset[key] ?? 0);
|
||||
};
|
||||
|
||||
const getMaxDragOffset = key => {
|
||||
const column = findColumn(key);
|
||||
if (!column) return { maxDown: 0, maxUp: 0 };
|
||||
const currentIdx = activeIndex[key] ?? 0;
|
||||
const firstEnabled = column.items.findIndex(i => !i.disabled);
|
||||
const lastEnabled = column.items.findLastIndex(i => !i.disabled);
|
||||
if (firstEnabled === -1) return { maxDown: 0, maxUp: 0 };
|
||||
return {
|
||||
maxDown: (currentIdx - firstEnabled) * ITEM_HEIGHT,
|
||||
maxUp: (lastEnabled - currentIdx) * ITEM_HEIGHT,
|
||||
};
|
||||
};
|
||||
|
||||
const clampDragOffset = (key, rawOffset) => {
|
||||
const { maxDown, maxUp } = getMaxDragOffset(key);
|
||||
return Math.max(-maxUp, Math.min(maxDown, rawOffset));
|
||||
};
|
||||
|
||||
let lastEmittedSignature = '';
|
||||
const emitTimeValue = () => {
|
||||
const sig = `${selectedTime.hour}:${selectedTime.minute}:${selectedTime.second}`;
|
||||
if (sig === lastEmittedSignature) return;
|
||||
lastEmittedSignature = sig;
|
||||
model.value = { ...selectedTime };
|
||||
};
|
||||
|
||||
const applySelection = (key, item) => {
|
||||
if (key === HOUR_12) {
|
||||
selectedTime.hour = convertTo24Hour(item.value, period.value);
|
||||
} else if (key === PERIOD) {
|
||||
selectedTime.hour = convertTo24Hour(
|
||||
convertTo12Hour(selectedTime.hour),
|
||||
item.value
|
||||
);
|
||||
} else {
|
||||
selectedTime[key] = item.value;
|
||||
}
|
||||
emitTimeValue();
|
||||
};
|
||||
|
||||
const selectIndex = (key, targetIndex) => {
|
||||
const column = findColumn(key);
|
||||
if (!column) return;
|
||||
const clamped = Math.max(0, Math.min(targetIndex, column.items.length - 1));
|
||||
const resolved = column.items[clamped]?.disabled
|
||||
? findNearestEnabled(column.items, clamped)
|
||||
: clamped;
|
||||
activeIndex[key] = resolved;
|
||||
const item = column.items[resolved];
|
||||
if (item) applySelection(key, item);
|
||||
};
|
||||
|
||||
const selectByValue = (key, value) => {
|
||||
const column = findColumn(key);
|
||||
if (!column) return;
|
||||
const idx = column.items.findIndex(i => i.value === value && !i.disabled);
|
||||
if (idx !== -1) selectIndex(key, idx);
|
||||
};
|
||||
|
||||
const snapToNearest = key => {
|
||||
const offset = dragOffset[key] ?? 0;
|
||||
const steps = Math.round(-offset / ITEM_HEIGHT);
|
||||
dragOffset[key] = 0;
|
||||
interactionMode[key] = null;
|
||||
if (steps !== 0) selectIndex(key, (activeIndex[key] ?? 0) + steps);
|
||||
};
|
||||
|
||||
const isItemSelected = (key, val) => {
|
||||
if (key === HOUR_12) return convertTo12Hour(selectedTime.hour) === val;
|
||||
if (key === PERIOD) return period.value === val;
|
||||
return selectedTime[key] === val;
|
||||
};
|
||||
|
||||
const isColumnHidden = key => key === PERIOD && !showPeriod.value;
|
||||
|
||||
const onWheelScroll = (key, event) => {
|
||||
event.preventDefault();
|
||||
interactionMode[key] = 'wheel';
|
||||
dragOffset[key] = clampDragOffset(key, (dragOffset[key] ?? 0) - event.deltaY);
|
||||
clearTimeout(snapTimers[key]);
|
||||
snapTimers[key] = setTimeout(() => snapToNearest(key), SNAP_DELAY);
|
||||
};
|
||||
|
||||
const onTouchStart = (key, event) => {
|
||||
interactionMode[key] = 'touch';
|
||||
touchStartY[key] = event.touches[0].clientY;
|
||||
dragOffset[key] = 0;
|
||||
};
|
||||
|
||||
const onTouchMove = (key, event) => {
|
||||
event.preventDefault();
|
||||
dragOffset[key] = clampDragOffset(
|
||||
key,
|
||||
event.touches[0].clientY - touchStartY[key]
|
||||
);
|
||||
};
|
||||
|
||||
const onTouchEnd = key => snapToNearest(key);
|
||||
|
||||
const visibleColumnKeys = computed(() =>
|
||||
columns.value.filter(col => !isColumnHidden(col.key)).map(col => col.key)
|
||||
);
|
||||
|
||||
const onKeyDown = (key, event) => {
|
||||
const { key: pressed, shiftKey } = event;
|
||||
if (pressed === 'ArrowUp' || pressed === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
selectIndex(
|
||||
key,
|
||||
(activeIndex[key] ?? 0) + (pressed === 'ArrowDown' ? 1 : -1)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (pressed === 'Tab') {
|
||||
const keys = visibleColumnKeys.value;
|
||||
const next = keys.indexOf(key) + (shiftKey ? -1 : 1);
|
||||
if (next >= 0 && next < keys.length) {
|
||||
event.preventDefault();
|
||||
columnRefs[keys[next]]?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const syncAllColumns = () => {
|
||||
columns.value.forEach(col => {
|
||||
activeIndex[col.key] = getActiveIndex(col.key);
|
||||
});
|
||||
};
|
||||
|
||||
const formatTabs = [
|
||||
{ label: t('DATE_PICKER.FORMAT_24H'), value: H24 },
|
||||
{ label: t('DATE_PICKER.FORMAT_12H'), value: H12 },
|
||||
];
|
||||
const activeFormatTab = computed(() => (is24HourFormat.value ? 0 : 1));
|
||||
|
||||
const onFormatChange = tab => {
|
||||
is24HourFormat.value = tab.value === H24;
|
||||
syncAllColumns();
|
||||
};
|
||||
|
||||
watch(model, v => {
|
||||
selectedTime.hour = v.hour;
|
||||
selectedTime.minute = v.minute;
|
||||
selectedTime.second = v.second ?? 0;
|
||||
syncAllColumns();
|
||||
});
|
||||
|
||||
onMounted(syncAllColumns);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex flex-col items-center py-2 w-full">
|
||||
<div class="mb-4">
|
||||
<TabBar
|
||||
:tabs="formatTabs"
|
||||
:initial-active-tab="activeFormatTab"
|
||||
@tab-changed="onFormatChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center z-[1] mb-1">
|
||||
<template v-for="(col, colIdx) in columns" :key="`label-${col.key}`">
|
||||
<span
|
||||
class="text-center text-xs font-medium text-n-slate-11 transition-all duration-300 ease-in-out overflow-hidden"
|
||||
:class="
|
||||
isColumnHidden(col.key) ? 'w-0 opacity-0' : 'w-14 opacity-100'
|
||||
"
|
||||
>
|
||||
{{ col.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="colIdx < columns.length - 1"
|
||||
class="text-lg font-520 text-transparent transition-all duration-300 ease-in-out overflow-hidden"
|
||||
:class="
|
||||
isColumnHidden(columns[colIdx + 1]?.key)
|
||||
? 'w-0 opacity-0'
|
||||
: 'opacity-100'
|
||||
"
|
||||
>
|
||||
{{ SEPARATOR }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="relative w-full">
|
||||
<div
|
||||
class="absolute inset-x-6 h-10 rounded-2xl bg-n-solid-active outline outline-1 -outline-offset-1 outline-n-weak pointer-events-none shadow-inner"
|
||||
:style="{ top: `${CENTER_ROW * ITEM_HEIGHT}px` }"
|
||||
/>
|
||||
<div class="flex items-center justify-center z-[1] relative">
|
||||
<template v-for="(col, colIdx) in columns" :key="col.key">
|
||||
<div
|
||||
:ref="
|
||||
el => {
|
||||
if (el) columnRefs[col.key] = el;
|
||||
}
|
||||
"
|
||||
:tabindex="isColumnHidden(col.key) ? -1 : 0"
|
||||
class="time-wheel relative overflow-hidden transition-all duration-300 ease-in-out outline-none"
|
||||
:class="
|
||||
isColumnHidden(col.key) ? 'w-0 opacity-0' : 'w-14 opacity-100'
|
||||
"
|
||||
:style="{ height: `${VISIBLE_ROWS * ITEM_HEIGHT}px` }"
|
||||
@wheel.prevent="onWheelScroll(col.key, $event)"
|
||||
@touchstart="onTouchStart(col.key, $event)"
|
||||
@touchmove.prevent="onTouchMove(col.key, $event)"
|
||||
@touchend="onTouchEnd(col.key)"
|
||||
@keydown="onKeyDown(col.key, $event)"
|
||||
@focus="focusedColumn = col.key"
|
||||
@blur="focusedColumn = null"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center"
|
||||
:class="{
|
||||
'transition-transform duration-200 ease-out':
|
||||
!interactionMode[col.key],
|
||||
'transition-transform duration-100 ease-out':
|
||||
interactionMode[col.key] === 'wheel',
|
||||
}"
|
||||
:style="{
|
||||
transform: `translateY(${getColumnTranslateY(col.key)}px)`,
|
||||
}"
|
||||
>
|
||||
<button
|
||||
v-for="item in col.items"
|
||||
:key="item.value"
|
||||
:disabled="item.disabled"
|
||||
tabindex="-1"
|
||||
class="flex items-center justify-center w-14 shrink-0 text-base font-semibold transition-colors outline-none"
|
||||
:style="{ height: `${ITEM_HEIGHT}px` }"
|
||||
:class="[
|
||||
isItemSelected(col.key, item.value)
|
||||
? 'text-n-slate-12'
|
||||
: 'text-n-slate-9',
|
||||
item.disabled
|
||||
? 'opacity-20 cursor-not-allowed'
|
||||
: 'cursor-pointer',
|
||||
isItemSelected(col.key, item.value) &&
|
||||
focusedColumn === col.key
|
||||
? 'outline outline-1 outline-n-brand -outline-offset-4 rounded-xl'
|
||||
: '',
|
||||
]"
|
||||
@click="selectByValue(col.key, item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="colIdx < columns.length - 1 && col.key !== SECOND"
|
||||
class="text-lg font-520 text-n-slate-11"
|
||||
>
|
||||
{{ SEPARATOR }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.time-wheel {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
rgba(0, 0, 0, 0.3) 15%,
|
||||
rgba(0, 0, 0, 0.7) 30%,
|
||||
black 40%,
|
||||
black 60%,
|
||||
rgba(0, 0, 0, 0.7) 70%,
|
||||
rgba(0, 0, 0, 0.3) 85%,
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
rgba(0, 0, 0, 0.3) 15%,
|
||||
rgba(0, 0, 0, 0.7) 30%,
|
||||
black 40%,
|
||||
black 60%,
|
||||
rgba(0, 0, 0, 0.7) 70%,
|
||||
rgba(0, 0, 0, 0.3) 85%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
</style>
|
||||
@@ -28,7 +28,7 @@ const props = defineProps({
|
||||
medium: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const emit = defineEmits(['update:modelValue', 'executeCopilotAction']);
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
@@ -113,6 +113,9 @@ watch(
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@execute-copilot-action="
|
||||
(...args) => emit('executeCopilotAction', ...args)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-if="showCharacterCount || slots.actions"
|
||||
|
||||
+12
-8
@@ -58,18 +58,22 @@ const openArticle = id => {
|
||||
}
|
||||
};
|
||||
|
||||
const onReorder = reorderedGroup => {
|
||||
store.dispatch('articles/reorder', {
|
||||
reorderedGroup,
|
||||
portalSlug: route.params.portalSlug,
|
||||
});
|
||||
const onReorder = async reorderedGroup => {
|
||||
try {
|
||||
await store.dispatch('articles/reorder', {
|
||||
reorderedGroup,
|
||||
portalSlug: route.params.portalSlug,
|
||||
});
|
||||
} catch {
|
||||
useAlert(t('HELP_CENTER.REORDER_ARTICLE.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
// Reuse existing positions to maintain order within the current group
|
||||
// Collect and sort existing positions, falling back to index+1 for null/0 values
|
||||
const sortedArticlePositions = localArticles.value
|
||||
.map(article => article.position)
|
||||
.sort((a, b) => a - b); // Use custom sort to handle numeric values correctly
|
||||
.map((article, index) => article.position || index + 1)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const orderedArticles = localArticles.value.map(article => article.id);
|
||||
|
||||
|
||||
+12
@@ -98,6 +98,17 @@ const handleAction = ({ action, id, category: categoryData }) => {
|
||||
deleteCategory(categoryData);
|
||||
}
|
||||
};
|
||||
|
||||
const reorderCategories = async reorderedGroup => {
|
||||
try {
|
||||
await store.dispatch('categories/reorder', {
|
||||
portalSlug: route.params.portalSlug,
|
||||
reorderedGroup,
|
||||
});
|
||||
} catch {
|
||||
useAlert(t('HELP_CENTER.REORDER_CATEGORY.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -122,6 +133,7 @@ const handleAction = ({ action, id, category: categoryData }) => {
|
||||
:categories="categories"
|
||||
@click="openCategoryArticles"
|
||||
@action="handleAction"
|
||||
@reorder="reorderCategories"
|
||||
/>
|
||||
<CategoryEmptyState
|
||||
v-else
|
||||
|
||||
+60
-16
@@ -1,14 +1,22 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import Draggable from 'vuedraggable';
|
||||
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'action']);
|
||||
const emit = defineEmits(['click', 'action', 'reorder']);
|
||||
|
||||
const localCategories = ref(props.categories);
|
||||
|
||||
const dragEnabled = computed(() => {
|
||||
return localCategories.value?.length > 1;
|
||||
});
|
||||
|
||||
const handleClick = slug => {
|
||||
emit('click', slug);
|
||||
@@ -17,21 +25,57 @@ const handleClick = slug => {
|
||||
const handleAction = ({ action, value, id }, category) => {
|
||||
emit('action', { action, value, id, category });
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
// Collect and sort existing positions, falling back to index+1 for null/0 values
|
||||
const sortedPositions = localCategories.value
|
||||
.map((category, index) => category.position || index + 1)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const reorderedGroup = localCategories.value.reduce(
|
||||
(obj, category, index) => {
|
||||
obj[category.id] = sortedPositions[index];
|
||||
return obj;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
emit('reorder', reorderedGroup);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.categories,
|
||||
newCategories => {
|
||||
localCategories.value = newCategories;
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul role="list" class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CategoryCard
|
||||
v-for="category in categories"
|
||||
:id="category.id"
|
||||
:key="category.id"
|
||||
:title="category.name"
|
||||
:icon="category.icon"
|
||||
:description="category.description"
|
||||
:articles-count="category.meta.articles_count || 0"
|
||||
:slug="category.slug"
|
||||
@click="handleClick(category.slug)"
|
||||
@action="handleAction($event, category)"
|
||||
/>
|
||||
</ul>
|
||||
<Draggable
|
||||
v-model="localCategories"
|
||||
:disabled="!dragEnabled"
|
||||
item-key="id"
|
||||
tag="ul"
|
||||
role="list"
|
||||
class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<li class="list-none">
|
||||
<CategoryCard
|
||||
:id="element.id"
|
||||
:title="element.name"
|
||||
:icon="element.icon"
|
||||
:description="element.description"
|
||||
:articles-count="element.meta?.articles_count || 0"
|
||||
:slug="element.slug"
|
||||
:class="{ 'cursor-grab': dragEnabled }"
|
||||
@click="handleClick(element.slug)"
|
||||
@action="handleAction($event, element)"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
</Draggable>
|
||||
</template>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import {
|
||||
searchContacts,
|
||||
createContactSearcher,
|
||||
createNewContact,
|
||||
fetchContactableInboxes,
|
||||
processContactableInboxes,
|
||||
@@ -39,6 +39,7 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const searchContacts = createContactSearcher();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
@@ -107,15 +108,17 @@ const onContactSearch = debounce(
|
||||
isSearching.value = true;
|
||||
contacts.value = [];
|
||||
try {
|
||||
contacts.value = await searchContacts(query);
|
||||
const results = await searchContacts(query);
|
||||
// null means the request was aborted (a newer search is in-flight),
|
||||
if (results === null) return;
|
||||
contacts.value = results;
|
||||
isSearching.value = false;
|
||||
} catch (error) {
|
||||
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
300,
|
||||
400,
|
||||
false
|
||||
);
|
||||
|
||||
@@ -138,6 +141,7 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
|
||||
contact = rest;
|
||||
}
|
||||
selectedContact.value = contact;
|
||||
contacts.value = [];
|
||||
if (contact?.id) {
|
||||
isFetchingInboxes.value = true;
|
||||
try {
|
||||
|
||||
+46
-3
@@ -15,6 +15,9 @@ import {
|
||||
prepareWhatsAppMessagePayload,
|
||||
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
|
||||
|
||||
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
|
||||
import ContactSelector from './ContactSelector.vue';
|
||||
import InboxSelector from './InboxSelector.vue';
|
||||
import EmailOptions from './EmailOptions.vue';
|
||||
@@ -22,6 +25,7 @@ import MessageEditor from './MessageEditor.vue';
|
||||
import ActionButtons from './ActionButtons.vue';
|
||||
import InboxEmptyState from './InboxEmptyState.vue';
|
||||
import AttachmentPreviews from './AttachmentPreviews.vue';
|
||||
import CopilotReplyBottomPanel from 'dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contacts: { type: Array, default: () => [] },
|
||||
@@ -42,6 +46,7 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits([
|
||||
'searchContacts',
|
||||
'resetContactSearch',
|
||||
'discard',
|
||||
'updateSelectedContact',
|
||||
'updateTargetInbox',
|
||||
@@ -51,6 +56,8 @@ const emit = defineEmits([
|
||||
|
||||
const DEFAULT_FORMATTING = 'Context::Default';
|
||||
|
||||
const copilot = useCopilotReply();
|
||||
|
||||
const showContactsDropdown = ref(false);
|
||||
const showInboxesDropdown = ref(false);
|
||||
const showCcEmailsDropdown = ref(false);
|
||||
@@ -157,7 +164,7 @@ const isAnyDropdownActive = computed(() => {
|
||||
});
|
||||
|
||||
const handleContactSearch = value => {
|
||||
showContactsDropdown.value = true;
|
||||
showContactsDropdown.value = value.trim().length > 1;
|
||||
emit('searchContacts', value);
|
||||
};
|
||||
|
||||
@@ -172,12 +179,16 @@ const handleDropdownUpdate = (type, value) => {
|
||||
};
|
||||
|
||||
const searchCcEmails = value => {
|
||||
showCcEmailsDropdown.value = true;
|
||||
showBccEmailsDropdown.value = false;
|
||||
emit('resetContactSearch');
|
||||
showCcEmailsDropdown.value = value.trim().length >= 2;
|
||||
emit('searchContacts', value);
|
||||
};
|
||||
|
||||
const searchBccEmails = value => {
|
||||
showBccEmailsDropdown.value = true;
|
||||
showCcEmailsDropdown.value = false;
|
||||
emit('resetContactSearch');
|
||||
showBccEmailsDropdown.value = value.trim().length >= 2;
|
||||
emit('searchContacts', value);
|
||||
};
|
||||
|
||||
@@ -196,6 +207,7 @@ const stripMessageFormatting = channelType => {
|
||||
|
||||
const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
|
||||
v$.value.$reset();
|
||||
copilot.reset(false);
|
||||
|
||||
// Strip unsupported formatting when changing the target inbox
|
||||
if (channelType) {
|
||||
@@ -222,6 +234,7 @@ const removeSignatureFromMessage = () => {
|
||||
|
||||
const removeTargetInbox = value => {
|
||||
v$.value.$reset();
|
||||
copilot.reset(false);
|
||||
removeSignatureFromMessage();
|
||||
|
||||
stripMessageFormatting(DEFAULT_FORMATTING);
|
||||
@@ -231,6 +244,7 @@ const removeTargetInbox = value => {
|
||||
};
|
||||
|
||||
const clearSelectedContact = () => {
|
||||
copilot.reset(false);
|
||||
removeSignatureFromMessage();
|
||||
emit('clearSelectedContact');
|
||||
state.message = '';
|
||||
@@ -262,6 +276,7 @@ const handleAttachFile = files => {
|
||||
};
|
||||
|
||||
const clearForm = () => {
|
||||
copilot.reset(false);
|
||||
Object.assign(state, {
|
||||
message: '',
|
||||
subject: '',
|
||||
@@ -324,6 +339,24 @@ const shouldShowMessageEditor = computed(() => {
|
||||
!inboxTypes.value.isTwilioWhatsapp
|
||||
);
|
||||
});
|
||||
|
||||
const isCopilotActive = computed(() => copilot.isActive?.value ?? false);
|
||||
|
||||
const onSubmitCopilotReply = () => {
|
||||
const acceptedMessage = copilot.accept();
|
||||
state.message = acceptedMessage;
|
||||
};
|
||||
|
||||
useKeyboardEvents({
|
||||
'$mod+Enter': {
|
||||
action: () => {
|
||||
if (isCopilotActive.value && !copilot.isButtonDisabled.value) {
|
||||
onSubmitCopilotReply();
|
||||
}
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -354,6 +387,7 @@ const shouldShowMessageEditor = computed(() => {
|
||||
:show-inboxes-dropdown="showInboxesDropdown"
|
||||
:contactable-inboxes-list="contactableInboxesList"
|
||||
:has-errors="validationStates.isInboxInvalid"
|
||||
:is-fetching-inboxes="isFetchingInboxes"
|
||||
@update-inbox="removeTargetInbox"
|
||||
@toggle-dropdown="showInboxesDropdown = $event"
|
||||
@handle-inbox-action="handleInboxAction"
|
||||
@@ -382,6 +416,7 @@ const shouldShowMessageEditor = computed(() => {
|
||||
:has-errors="validationStates.isMessageInvalid"
|
||||
:channel-type="inboxChannelType"
|
||||
:medium="targetInbox?.medium || ''"
|
||||
:copilot="copilot"
|
||||
/>
|
||||
|
||||
<AttachmentPreviews
|
||||
@@ -391,7 +426,15 @@ const shouldShowMessageEditor = computed(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CopilotReplyBottomPanel
|
||||
v-if="isCopilotActive"
|
||||
:is-generating-content="copilot.isButtonDisabled.value"
|
||||
class="h-[3.25rem] !px-4 !py-2"
|
||||
@submit="onSubmitCopilotReply"
|
||||
@cancel="copilot.reset"
|
||||
/>
|
||||
<ActionButtons
|
||||
v-else
|
||||
:attached-files="state.attachedFiles"
|
||||
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
|
||||
@@ -99,7 +99,6 @@ const inputClass = computed(() => {
|
||||
type="email"
|
||||
allow-create
|
||||
class="flex-1 min-h-7"
|
||||
@focus="emit('updateDropdown', 'cc', true)"
|
||||
@input="emit('searchCcEmails', $event)"
|
||||
@on-click-outside="emit('updateDropdown', 'cc', false)"
|
||||
@update:model-value="handleCcUpdate"
|
||||
@@ -133,7 +132,6 @@ const inputClass = computed(() => {
|
||||
allow-create
|
||||
class="flex-1 min-h-7"
|
||||
focus-on-mount
|
||||
@focus="emit('updateDropdown', 'bcc', true)"
|
||||
@input="emit('searchBccEmails', $event)"
|
||||
@on-click-outside="emit('updateDropdown', 'bcc', false)"
|
||||
@update:model-value="handleBccUpdate"
|
||||
|
||||
+7
-1
@@ -1,9 +1,15 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center w-full px-4 py-3 dark:bg-n-amber-11/15 bg-n-amber-3"
|
||||
>
|
||||
<span class="text-sm dark:text-n-amber-11 text-n-amber-11">
|
||||
{{ $t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }}
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { generateLabelForContactableInboxesList } from 'dashboard/components-nex
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
targetInbox: {
|
||||
@@ -28,6 +29,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isFetchingInboxes: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -71,7 +76,9 @@ const targetInboxLabel = computed(() => {
|
||||
v-on-click-outside="() => emit('toggleDropdown', false)"
|
||||
class="relative flex items-center h-7"
|
||||
>
|
||||
<Spinner v-if="isFetchingInboxes" :size="16" />
|
||||
<Button
|
||||
v-else
|
||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.BUTTON')"
|
||||
variant="link"
|
||||
size="sm"
|
||||
|
||||
+61
-21
@@ -3,6 +3,7 @@ import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import CopilotEditorSection from 'dashboard/components/widgets/conversation/CopilotEditorSection.vue';
|
||||
|
||||
const props = defineProps({
|
||||
hasErrors: { type: Boolean, default: false },
|
||||
@@ -10,6 +11,7 @@ const props = defineProps({
|
||||
messageSignature: { type: String, default: '' },
|
||||
channelType: { type: String, default: '' },
|
||||
medium: { type: String, default: '' },
|
||||
copilot: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
|
||||
@@ -20,29 +22,67 @@ const modelValue = defineModel({
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const isCopilotActive = computed(() => props.copilot?.isActive?.value ?? false);
|
||||
|
||||
const executeCopilotAction = (action, data) => {
|
||||
if (props.copilot) {
|
||||
props.copilot.execute(action, data);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 h-full">
|
||||
<Editor
|
||||
v-model="modelValue"
|
||||
:editor-key="editorKey"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[10rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
|
||||
:class="
|
||||
hasErrors
|
||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
enable-variables
|
||||
:show-character-count="false"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
:medium="medium"
|
||||
/>
|
||||
<div class="flex-1 h-full px-4 py-4">
|
||||
<Transition
|
||||
mode="out-in"
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
|
||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
|
||||
>
|
||||
<div
|
||||
:key="copilot ? copilot.editorTransitionKey.value : 'rich'"
|
||||
class="h-full"
|
||||
>
|
||||
<CopilotEditorSection
|
||||
v-if="isCopilotActive"
|
||||
:show-copilot-editor="copilot.showEditor.value"
|
||||
:is-generating-content="copilot.isGenerating.value"
|
||||
:generated-content="copilot.generatedContent.value"
|
||||
class="!mb-0"
|
||||
@focus="() => {}"
|
||||
@blur="() => {}"
|
||||
@clear-selection="() => {}"
|
||||
@content-ready="copilot.setContentReady"
|
||||
@send="copilot.sendFollowUp"
|
||||
/>
|
||||
<Editor
|
||||
v-else
|
||||
v-model="modelValue"
|
||||
:editor-key="editorKey"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div]:!border-transparent [&>div]:px-0 [&>div]:py-0 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[12rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
|
||||
:class="
|
||||
hasErrors
|
||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
enable-variables
|
||||
enable-captain-tools
|
||||
:show-character-count="false"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
:medium="medium"
|
||||
@execute-copilot-action="executeCopilotAction"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+35
-12
@@ -177,19 +177,42 @@ export const prepareWhatsAppMessagePayload = ({
|
||||
};
|
||||
|
||||
// API Calls
|
||||
export const searchContacts = async query => {
|
||||
const trimmed = typeof query === 'string' ? query.trim() : '';
|
||||
if (!trimmed) return [];
|
||||
const MIN_SEARCH_LENGTH = 2;
|
||||
|
||||
const {
|
||||
data: { payload },
|
||||
} = await ContactAPI.search(trimmed);
|
||||
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
|
||||
// Filter contacts that have either phone_number or email
|
||||
const filteredPayload = camelCasedPayload?.filter(
|
||||
contact => contact.phoneNumber || contact.email
|
||||
);
|
||||
return filteredPayload || [];
|
||||
export const createContactSearcher = () => {
|
||||
let controller = null;
|
||||
|
||||
return async (query, { skipMinLength = false } = {}) => {
|
||||
const trimmed = typeof query === 'string' ? query.trim() : '';
|
||||
|
||||
controller?.abort();
|
||||
|
||||
if (!trimmed || (!skipMinLength && trimmed.length < MIN_SEARCH_LENGTH))
|
||||
return [];
|
||||
|
||||
controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await ContactAPI.search(trimmed, 1, 'name', '', { signal });
|
||||
|
||||
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
|
||||
// Filter contacts that have either phone_number or email
|
||||
const filteredPayload = camelCasedPayload?.filter(
|
||||
contact => contact.phoneNumber || contact.email
|
||||
);
|
||||
return filteredPayload || [];
|
||||
} catch (error) {
|
||||
// Return null for aborted requests so callers can distinguish
|
||||
// "request was cancelled" from "no results found"
|
||||
if (error?.name === 'AbortError' || error?.name === 'CanceledError') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const createNewContact = async input => {
|
||||
|
||||
+97
-7
@@ -337,7 +337,12 @@ describe('composeConversationHelper', () => {
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
describe('searchContacts', () => {
|
||||
describe('createContactSearcher', () => {
|
||||
let searchContacts;
|
||||
beforeEach(() => {
|
||||
searchContacts = helpers.createContactSearcher();
|
||||
});
|
||||
|
||||
it('searches contacts and returns camelCase results', async () => {
|
||||
const mockPayload = [
|
||||
{
|
||||
@@ -353,7 +358,7 @@ describe('composeConversationHelper', () => {
|
||||
data: { payload: mockPayload },
|
||||
});
|
||||
|
||||
const result = await helpers.searchContacts('john');
|
||||
const result = await searchContacts('john');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
@@ -365,7 +370,56 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(ContactAPI.search).toHaveBeenCalledWith('john');
|
||||
expect(ContactAPI.search).toHaveBeenCalledWith(
|
||||
'john',
|
||||
1,
|
||||
'name',
|
||||
'',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty array for queries shorter than 2 characters', async () => {
|
||||
const result = await searchContacts('j');
|
||||
expect(result).toEqual([]);
|
||||
expect(ContactAPI.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns empty array for empty or whitespace-only queries', async () => {
|
||||
expect(await searchContacts('')).toEqual([]);
|
||||
expect(await searchContacts(' ')).toEqual([]);
|
||||
expect(await searchContacts(null)).toEqual([]);
|
||||
expect(ContactAPI.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts previous in-flight request when a new search starts', async () => {
|
||||
const mockPayload = [
|
||||
{ id: 1, name: 'Result', email: 'r@test.com', phone_number: null },
|
||||
];
|
||||
|
||||
let resolveFirst;
|
||||
const firstCall = new Promise(resolve => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
ContactAPI.search
|
||||
.mockReturnValueOnce(firstCall)
|
||||
.mockResolvedValueOnce({ data: { payload: mockPayload } });
|
||||
|
||||
// Start first search (will hang)
|
||||
const first = searchContacts('alpha');
|
||||
// Start second search (aborts first)
|
||||
const second = searchContacts('beta');
|
||||
|
||||
// Resolve the first call with CanceledError (simulating axios abort)
|
||||
const canceledError = new Error('canceled');
|
||||
canceledError.name = 'CanceledError';
|
||||
resolveFirst(Promise.reject(canceledError));
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||
expect(firstResult).toBeNull();
|
||||
expect(secondResult).toEqual([
|
||||
{ id: 1, name: 'Result', email: 'r@test.com', phoneNumber: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('searches contacts and returns only contacts with email or phone number', async () => {
|
||||
@@ -397,7 +451,7 @@ describe('composeConversationHelper', () => {
|
||||
data: { payload: mockPayload },
|
||||
});
|
||||
|
||||
const result = await helpers.searchContacts('john');
|
||||
const result = await searchContacts('john');
|
||||
|
||||
// Should only return contacts with either email or phone number
|
||||
expect(result).toEqual([
|
||||
@@ -417,7 +471,13 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(ContactAPI.search).toHaveBeenCalledWith('john');
|
||||
expect(ContactAPI.search).toHaveBeenCalledWith(
|
||||
'john',
|
||||
1,
|
||||
'name',
|
||||
'',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
);
|
||||
});
|
||||
|
||||
it('handles empty search results', async () => {
|
||||
@@ -425,7 +485,7 @@ describe('composeConversationHelper', () => {
|
||||
data: { payload: [] },
|
||||
});
|
||||
|
||||
const result = await helpers.searchContacts('nonexistent');
|
||||
const result = await searchContacts('nonexistent');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -452,7 +512,7 @@ describe('composeConversationHelper', () => {
|
||||
data: { payload: mockPayload },
|
||||
});
|
||||
|
||||
const result = await helpers.searchContacts('test');
|
||||
const result = await searchContacts('test');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
@@ -474,6 +534,36 @@ describe('composeConversationHelper', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createContactSearcher isolation', () => {
|
||||
it('creates isolated searcher instances that do not cancel each other', async () => {
|
||||
const searcherA = helpers.createContactSearcher();
|
||||
const searcherB = helpers.createContactSearcher();
|
||||
|
||||
const payloadA = [
|
||||
{ id: 1, name: 'Alice', email: 'a@test.com', phone_number: null },
|
||||
];
|
||||
const payloadB = [
|
||||
{ id: 2, name: 'Bob', email: 'b@test.com', phone_number: null },
|
||||
];
|
||||
|
||||
ContactAPI.search
|
||||
.mockResolvedValueOnce({ data: { payload: payloadA } })
|
||||
.mockResolvedValueOnce({ data: { payload: payloadB } });
|
||||
|
||||
const [resultA, resultB] = await Promise.all([
|
||||
searcherA('alice'),
|
||||
searcherB('bob'),
|
||||
]);
|
||||
|
||||
expect(resultA).toEqual([
|
||||
{ id: 1, name: 'Alice', email: 'a@test.com', phoneNumber: null },
|
||||
]);
|
||||
expect(resultB).toEqual([
|
||||
{ id: 2, name: 'Bob', email: 'b@test.com', phoneNumber: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNewContact', () => {
|
||||
it('creates new contact with capitalized name', async () => {
|
||||
const mockContact = { id: 1, name: 'John', email: 'john@example.com' };
|
||||
|
||||
@@ -107,7 +107,7 @@ defineExpose({ open, close });
|
||||
<TeleportWithDirection to="body">
|
||||
<dialog
|
||||
ref="dialogRef"
|
||||
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl focus-within:outline-none focus-within:outline-0"
|
||||
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl"
|
||||
:class="[
|
||||
maxWidthClass,
|
||||
positionClass,
|
||||
|
||||
@@ -81,6 +81,7 @@ const isDelivered = computed(() => {
|
||||
isATwilioChannel.value ||
|
||||
isASmsInbox.value ||
|
||||
isAFacebookInbox.value ||
|
||||
isAnInstagramChannel.value ||
|
||||
isATiktokChannel.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
|
||||
|
||||
@@ -72,7 +72,7 @@ const isNewTagInValidType = computed(() =>
|
||||
|
||||
const showInput = computed(() =>
|
||||
props.mode === MODE.SINGLE
|
||||
? isFocused.value && !tags.value.length
|
||||
? !tags.value.length
|
||||
: isFocused.value || !tags.value.length
|
||||
);
|
||||
|
||||
|
||||
@@ -1,53 +1,77 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import DatePicker from 'dashboard/components-next/DatePicker/DatePicker.vue';
|
||||
<script>
|
||||
import DatePicker from 'vue-datepicker-next';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const emit = defineEmits(['chooseTime', 'close']);
|
||||
export default {
|
||||
components: {
|
||||
DatePicker,
|
||||
NextButton,
|
||||
},
|
||||
emits: ['close', 'chooseTime'],
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const datePickerRef = ref(null);
|
||||
const today = new Date();
|
||||
data() {
|
||||
return {
|
||||
snoozeTime: null,
|
||||
lang: {
|
||||
days: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
|
||||
yearFormat: 'YYYY',
|
||||
monthFormat: 'MMMM',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
const onApply = dateTime => {
|
||||
dialogRef.value?.close();
|
||||
emit('chooseTime', dateTime);
|
||||
methods: {
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
chooseTime() {
|
||||
this.$emit('chooseTime', this.snoozeTime);
|
||||
},
|
||||
disabledDate(date) {
|
||||
// Disable all the previous dates
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return date < yesterday;
|
||||
},
|
||||
disabledTime(date) {
|
||||
// Allow only time after 1 hour
|
||||
const now = new Date();
|
||||
now.setHours(now.getHours() + 1);
|
||||
return date < now;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const onClear = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
const onDialogClose = () => {
|
||||
datePickerRef.value?.resetState();
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="$t('CONVERSATION.CUSTOM_SNOOZE.TITLE')"
|
||||
:show-confirm-button="false"
|
||||
:show-cancel-button="false"
|
||||
width="2xl"
|
||||
@close="onDialogClose"
|
||||
>
|
||||
<DatePicker
|
||||
ref="datePickerRef"
|
||||
:min-date="today"
|
||||
@apply="onApply"
|
||||
@clear="onClear"
|
||||
/>
|
||||
</Dialog>
|
||||
<div class="flex flex-col">
|
||||
<woot-modal-header :header-title="$t('CONVERSATION.CUSTOM_SNOOZE.TITLE')" />
|
||||
<form
|
||||
class="modal-content w-full pt-2 px-5 pb-6"
|
||||
@submit.prevent="chooseTime"
|
||||
>
|
||||
<DatePicker
|
||||
v-model:value="snoozeTime"
|
||||
type="datetime"
|
||||
inline
|
||||
input-class="mx-input "
|
||||
:lang="lang"
|
||||
:disabled-date="disabledDate"
|
||||
:disabled-time="disabledTime"
|
||||
/>
|
||||
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
|
||||
<NextButton
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('CONVERSATION.CUSTOM_SNOOZE.CANCEL')"
|
||||
@click.prevent="onClose"
|
||||
/>
|
||||
<NextButton
|
||||
type="submit"
|
||||
:label="$t('CONVERSATION.CUSTOM_SNOOZE.APPLY')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -17,6 +17,7 @@ import Modal from './Modal.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import Tabs from './ui/Tabs/Tabs.vue';
|
||||
import TabsItem from './ui/Tabs/TabsItem.vue';
|
||||
import DatePicker from './ui/DatePicker/DatePicker.vue';
|
||||
|
||||
const WootUIKit = {
|
||||
Code,
|
||||
@@ -36,6 +37,7 @@ const WootUIKit = {
|
||||
Spinner,
|
||||
Tabs,
|
||||
TabsItem,
|
||||
DatePicker,
|
||||
install(Vue) {
|
||||
const keys = Object.keys(this);
|
||||
keys.pop(); // remove 'install' from keys
|
||||
|
||||
+1
-15
@@ -1,5 +1,4 @@
|
||||
<script setup>
|
||||
import { startOfDay } from 'date-fns';
|
||||
import {
|
||||
monthName,
|
||||
yearName,
|
||||
@@ -29,10 +28,6 @@ const props = defineProps({
|
||||
selectingEndDate: Boolean,
|
||||
selectedEndDate: Date,
|
||||
hoveredEndDate: Date,
|
||||
minDate: {
|
||||
type: Date,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -46,18 +41,11 @@ const emit = defineEmits([
|
||||
const { START_CALENDAR } = CALENDAR_TYPES;
|
||||
const { MONTH } = CALENDAR_PERIODS;
|
||||
|
||||
const isDayDisabled = day => {
|
||||
if (!props.minDate) return false;
|
||||
return startOfDay(day) < startOfDay(props.minDate);
|
||||
};
|
||||
|
||||
const emitHoveredEndDate = day => {
|
||||
if (isDayDisabled(day)) return;
|
||||
emit('updateHoveredEndDate', day);
|
||||
};
|
||||
|
||||
const emitSelectDate = day => {
|
||||
if (isDayDisabled(day)) return;
|
||||
emit('selectDate', day);
|
||||
};
|
||||
const onClickPrev = () => {
|
||||
@@ -120,10 +108,8 @@ const isNextDayInRange = day => {
|
||||
|
||||
const dayClasses = day => ({
|
||||
'text-n-slate-10 pointer-events-none': !isInCurrentMonth(day),
|
||||
'text-n-slate-10 pointer-events-none opacity-40':
|
||||
isInCurrentMonth(day) && isDayDisabled(day),
|
||||
'text-n-slate-12 hover:text-n-slate-12 hover:bg-n-blue-6 dark:hover:bg-n-blue-7':
|
||||
isInCurrentMonth(day) && !isDayDisabled(day),
|
||||
isInCurrentMonth(day),
|
||||
'bg-n-brand text-white':
|
||||
isSelectedStartOrEndDate(day) && isInCurrentMonth(day),
|
||||
'bg-n-blue-4 dark:bg-n-blue-5':
|
||||
-18
@@ -81,24 +81,6 @@ export const CALENDAR_PERIODS = {
|
||||
YEAR: 'year',
|
||||
};
|
||||
|
||||
export const TIME_COLUMNS = {
|
||||
HOUR: 'hour',
|
||||
HOUR_12: 'hour12',
|
||||
MINUTE: 'minute',
|
||||
SECOND: 'second',
|
||||
PERIOD: 'period',
|
||||
};
|
||||
|
||||
export const TIME_PERIODS = {
|
||||
AM: 'AM',
|
||||
PM: 'PM',
|
||||
};
|
||||
|
||||
export const TIME_FORMATS = {
|
||||
H24: '24h',
|
||||
H12: '12h',
|
||||
};
|
||||
|
||||
// Utility functions for date operations
|
||||
export const monthName = currentDate => format(currentDate, 'MMMM');
|
||||
export const yearName = currentDate => format(currentDate, 'yyyy');
|
||||
@@ -0,0 +1,41 @@
|
||||
<script>
|
||||
import DatePicker from 'vue-datepicker-next';
|
||||
export default {
|
||||
components: { DatePicker },
|
||||
props: {
|
||||
confirmText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
value: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ['change'],
|
||||
methods: {
|
||||
handleChange(value) {
|
||||
this.$emit('change', value);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="date-picker">
|
||||
<DatePicker
|
||||
range
|
||||
confirm
|
||||
:clearable="false"
|
||||
:editable="false"
|
||||
:confirm-text="confirmText"
|
||||
:placeholder="placeholder"
|
||||
:value="value"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script>
|
||||
import addDays from 'date-fns/addDays';
|
||||
import DatePicker from 'vue-datepicker-next';
|
||||
export default {
|
||||
components: { DatePicker },
|
||||
props: {
|
||||
confirmText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
value: {
|
||||
type: Date,
|
||||
default: [],
|
||||
},
|
||||
},
|
||||
emits: ['change'],
|
||||
|
||||
methods: {
|
||||
handleChange(value) {
|
||||
this.$emit('change', value);
|
||||
},
|
||||
disableBeforeToday(date) {
|
||||
const yesterdayDate = addDays(new Date(), -1);
|
||||
return date < yesterdayDate;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="date-picker">
|
||||
<DatePicker
|
||||
type="datetime"
|
||||
confirm
|
||||
:clearable="false"
|
||||
:editable="false"
|
||||
:confirm-text="confirmText"
|
||||
:placeholder="placeholder"
|
||||
:value="value"
|
||||
:disabled-date="disableBeforeToday"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,11 +10,23 @@ import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
hasSelection: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isEditorMenuPopover: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
editorContent: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
conversationId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['executeCopilotAction']);
|
||||
@@ -25,6 +37,13 @@ const { draftMessage } = useCaptain();
|
||||
|
||||
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
|
||||
|
||||
// When editorContent prop is passed, use it exclusively (even if empty)
|
||||
// This ensures each editor instance shows menu items based on its own content
|
||||
// Falls back to global draftMessage only when editorContent is not provided
|
||||
const effectiveContent = computed(() =>
|
||||
props.editorContent !== undefined ? props.editorContent : draftMessage.value
|
||||
);
|
||||
|
||||
// Selection-based menu items (when text is selected)
|
||||
const menuItems = computed(() => {
|
||||
const items = [];
|
||||
@@ -42,8 +61,9 @@ const menuItems = computed(() => {
|
||||
icon: 'i-fluent-pen-sparkle-24-regular',
|
||||
});
|
||||
} else if (
|
||||
props.conversationId &&
|
||||
replyMode.value === REPLY_EDITOR_MODES.REPLY &&
|
||||
draftMessage.value
|
||||
effectiveContent.value
|
||||
) {
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
|
||||
@@ -52,7 +72,7 @@ const menuItems = computed(() => {
|
||||
});
|
||||
}
|
||||
|
||||
if (draftMessage.value) {
|
||||
if (effectiveContent.value) {
|
||||
items.push(
|
||||
{
|
||||
label: t(
|
||||
@@ -105,7 +125,7 @@ const menuItems = computed(() => {
|
||||
|
||||
const generalMenuItems = computed(() => {
|
||||
const items = [];
|
||||
if (replyMode.value === REPLY_EDITOR_MODES.REPLY) {
|
||||
if (props.conversationId && replyMode.value === REPLY_EDITOR_MODES.REPLY) {
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUGGESTION'),
|
||||
key: 'reply_suggestion',
|
||||
@@ -113,7 +133,10 @@ const generalMenuItems = computed(() => {
|
||||
});
|
||||
}
|
||||
|
||||
if (replyMode.value === REPLY_EDITOR_MODES.NOTE || true) {
|
||||
if (
|
||||
props.conversationId &&
|
||||
(replyMode.value === REPLY_EDITOR_MODES.NOTE || true)
|
||||
) {
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUMMARIZE'),
|
||||
key: 'summarize',
|
||||
@@ -176,8 +199,8 @@ const handleSubMenuItemClick = (parentItem, subItem) => {
|
||||
<DropdownBody
|
||||
ref="menuRef"
|
||||
class="min-w-56 [&>ul]:gap-3 z-50 [&>ul]:px-4 [&>ul]:py-3.5"
|
||||
:class="{ 'selection-menu': hasSelection }"
|
||||
:style="hasSelection ? selectionMenuStyle : {}"
|
||||
:class="{ 'selection-menu': hasSelection && isEditorMenuPopover }"
|
||||
:style="hasSelection && isEditorMenuPopover ? selectionMenuStyle : {}"
|
||||
>
|
||||
<div v-if="menuItems.length > 0" class="flex flex-col items-start gap-2.5">
|
||||
<div
|
||||
|
||||
@@ -202,6 +202,11 @@ const editorRoot = useTemplateRef('editorRoot');
|
||||
const imageUpload = useTemplateRef('imageUpload');
|
||||
const editor = useTemplateRef('editor');
|
||||
|
||||
const isEditorMenuPopover = computed(
|
||||
() =>
|
||||
editorRoot.value?.classList.contains('popover-prosemirror-menu') ?? false
|
||||
);
|
||||
|
||||
const handleCopilotAction = actionKey => {
|
||||
if (actionKey === 'improve_selection' && editorView?.state) {
|
||||
const { from, to } = editorView.state.selection;
|
||||
@@ -211,7 +216,7 @@ const handleCopilotAction = actionKey => {
|
||||
emit('executeCopilotAction', 'improve', selectedText);
|
||||
}
|
||||
} else {
|
||||
emit('executeCopilotAction', actionKey);
|
||||
emit('executeCopilotAction', actionKey, props.modelValue);
|
||||
}
|
||||
|
||||
showSelectionMenu.value = false;
|
||||
@@ -484,6 +489,7 @@ function setToolbarPosition() {
|
||||
function setMenubarPosition({ selection } = {}) {
|
||||
const wrapper = editorRoot.value;
|
||||
if (!selection || !wrapper) return;
|
||||
if (!isEditorMenuPopover.value) return;
|
||||
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const isRtl = getComputedStyle(wrapper).direction === 'rtl';
|
||||
@@ -866,8 +872,12 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
v-if="showSelectionMenu"
|
||||
v-on-click-outside="handleClickOutside"
|
||||
:has-selection="isTextSelected"
|
||||
:is-editor-menu-popover="isEditorMenuPopover"
|
||||
:editor-content="modelValue"
|
||||
:conversation-id="conversationId"
|
||||
:show-selection-menu="showSelectionMenu"
|
||||
:show-general-menu="false"
|
||||
class="copilot-editor-menu"
|
||||
@execute-copilot-action="handleCopilotAction"
|
||||
/>
|
||||
<input
|
||||
@@ -1026,6 +1036,17 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
|
||||
}
|
||||
|
||||
// Default copilot menu position (non-popover editors like components-next/Editor)
|
||||
// When popover-prosemirror-menu is NOT on the wrapper, anchor below the menubar
|
||||
:not(.popover-prosemirror-menu) > .copilot-editor-menu {
|
||||
top: 1.5rem !important;
|
||||
|
||||
[dir='rtl'] & {
|
||||
left: auto !important;
|
||||
right: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Float editor menu
|
||||
.popover-prosemirror-menu {
|
||||
position: relative;
|
||||
|
||||
@@ -49,6 +49,10 @@ export default {
|
||||
type: Number,
|
||||
default: () => 0,
|
||||
},
|
||||
editorContent: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
|
||||
setup(props, { emit }) {
|
||||
@@ -73,8 +77,8 @@ export default {
|
||||
const { captainTasksEnabled } = useCaptain();
|
||||
const showCopilotMenu = ref(false);
|
||||
|
||||
const handleCopilotAction = actionKey => {
|
||||
emit('executeCopilotAction', actionKey);
|
||||
const handleCopilotAction = (actionKey, data) => {
|
||||
emit('executeCopilotAction', actionKey, data || props.editorContent);
|
||||
showCopilotMenu.value = false;
|
||||
};
|
||||
|
||||
@@ -174,6 +178,8 @@ export default {
|
||||
v-if="showCopilotMenu"
|
||||
v-on-click-outside="handleClickOutside"
|
||||
:has-selection="false"
|
||||
:editor-content="editorContent"
|
||||
:conversation-id="conversationId"
|
||||
class="ltr:right-0 rtl:left-0 bottom-full mb-2"
|
||||
@execute-copilot-action="handleCopilotAction"
|
||||
/>
|
||||
|
||||
@@ -1245,6 +1245,7 @@ export default {
|
||||
:is-editor-disabled="isEditorDisabled"
|
||||
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
|
||||
:characters-remaining="charactersRemaining"
|
||||
:editor-content="message"
|
||||
:popout-reply-box="popOutReplyBox"
|
||||
@set-reply-mode="setReplyMode"
|
||||
@toggle-popout="togglePopout"
|
||||
|
||||
+15
-4
@@ -65,6 +65,7 @@ export default {
|
||||
showLabelActions: false,
|
||||
showTeamsList: false,
|
||||
popoverPositions: {},
|
||||
showCustomTimeSnoozeModal: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
@@ -98,9 +99,7 @@ export default {
|
||||
methods: {
|
||||
onCmdSnoozeConversation(snoozeType) {
|
||||
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
this.$refs.snoozeModalRef?.open();
|
||||
} else if (typeof snoozeType === 'number') {
|
||||
this.updateConversations('snoozed', snoozeType);
|
||||
this.showCustomTimeSnoozeModal = true;
|
||||
} else {
|
||||
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
|
||||
}
|
||||
@@ -112,10 +111,14 @@ export default {
|
||||
this.updateConversations('resolved', null);
|
||||
},
|
||||
customSnoozeTime(customSnoozedTime) {
|
||||
this.showCustomTimeSnoozeModal = false;
|
||||
if (customSnoozedTime) {
|
||||
this.updateConversations('snoozed', getUnixTime(customSnoozedTime));
|
||||
}
|
||||
},
|
||||
hideCustomSnoozeModal() {
|
||||
this.showCustomTimeSnoozeModal = false;
|
||||
},
|
||||
selectAll(e) {
|
||||
this.$emit('selectAllConversations', e.target.checked);
|
||||
},
|
||||
@@ -246,7 +249,15 @@ export default {
|
||||
<div v-if="allConversationsSelected" class="bulk-action__alert">
|
||||
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
|
||||
</div>
|
||||
<CustomSnoozeModal ref="snoozeModalRef" @choose-time="customSnoozeTime" />
|
||||
<woot-modal
|
||||
v-model:show="showCustomTimeSnoozeModal"
|
||||
:on-close="hideCustomSnoozeModal"
|
||||
>
|
||||
<CustomSnoozeModal
|
||||
@close="hideCustomSnoozeModal"
|
||||
@choose-time="customSnoozeTime"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* snoozeDateParser — Natural language date/time parser for snooze.
|
||||
*
|
||||
* Barrel re-export from submodules:
|
||||
* - parser.js: core parsing engine (parseDateFromText)
|
||||
* - localization.js: multilingual suggestion generator (generateDateSuggestions)
|
||||
* - suggestions.js: compositional suggestion engine
|
||||
* - tokenMaps.js: shared token maps and utility functions
|
||||
*/
|
||||
|
||||
export { parseDateFromText } from './parser';
|
||||
export { generateDateSuggestions } from './localization';
|
||||
@@ -1,380 +0,0 @@
|
||||
/**
|
||||
* Handles non-English input and generates the final suggestion list.
|
||||
* Translates localized words to English before parsing, then converts
|
||||
* suggestion labels back to the user's language for display.
|
||||
*/
|
||||
|
||||
import {
|
||||
WEEKDAY_MAP,
|
||||
MONTH_MAP,
|
||||
UNIT_MAP,
|
||||
WORD_NUMBER_MAP,
|
||||
RELATIVE_DAY_MAP,
|
||||
TIME_OF_DAY_MAP,
|
||||
sanitize,
|
||||
stripNoise,
|
||||
normalizeDigits,
|
||||
} from './tokenMaps';
|
||||
|
||||
import { parseDateFromText } from './parser';
|
||||
import { buildSuggestionCandidates, MAX_SUGGESTIONS } from './suggestions';
|
||||
|
||||
// ─── English Reference Data ─────────────────────────────────────────────────
|
||||
|
||||
const EN_WEEKDAYS_LIST = [
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
'sunday',
|
||||
];
|
||||
|
||||
const EN_MONTHS_LIST = [
|
||||
'january',
|
||||
'february',
|
||||
'march',
|
||||
'april',
|
||||
'may',
|
||||
'june',
|
||||
'july',
|
||||
'august',
|
||||
'september',
|
||||
'october',
|
||||
'november',
|
||||
'december',
|
||||
];
|
||||
|
||||
const EN_DEFAULTS = {
|
||||
UNITS: {
|
||||
MINUTE: 'minute',
|
||||
MINUTES: 'minutes',
|
||||
HOUR: 'hour',
|
||||
HOURS: 'hours',
|
||||
DAY: 'day',
|
||||
DAYS: 'days',
|
||||
WEEK: 'week',
|
||||
WEEKS: 'weeks',
|
||||
MONTH: 'month',
|
||||
MONTHS: 'months',
|
||||
YEAR: 'year',
|
||||
YEARS: 'years',
|
||||
},
|
||||
RELATIVE: {
|
||||
TOMORROW: 'tomorrow',
|
||||
DAY_AFTER_TOMORROW: 'day after tomorrow',
|
||||
NEXT_WEEK: 'next week',
|
||||
NEXT_MONTH: 'next month',
|
||||
THIS_WEEKEND: 'this weekend',
|
||||
NEXT_WEEKEND: 'next weekend',
|
||||
},
|
||||
TIME_OF_DAY: {
|
||||
MORNING: 'morning',
|
||||
AFTERNOON: 'afternoon',
|
||||
EVENING: 'evening',
|
||||
NIGHT: 'night',
|
||||
NOON: 'noon',
|
||||
MIDNIGHT: 'midnight',
|
||||
},
|
||||
WORD_NUMBERS: {
|
||||
ONE: 'one',
|
||||
TWO: 'two',
|
||||
THREE: 'three',
|
||||
FOUR: 'four',
|
||||
FIVE: 'five',
|
||||
SIX: 'six',
|
||||
SEVEN: 'seven',
|
||||
EIGHT: 'eight',
|
||||
NINE: 'nine',
|
||||
TEN: 'ten',
|
||||
TWELVE: 'twelve',
|
||||
FIFTEEN: 'fifteen',
|
||||
TWENTY: 'twenty',
|
||||
THIRTY: 'thirty',
|
||||
},
|
||||
MERIDIEM: { AM: 'am', PM: 'pm' },
|
||||
HALF: 'half',
|
||||
NEXT: 'next',
|
||||
THIS: 'this',
|
||||
AT: 'at',
|
||||
IN: 'in',
|
||||
FROM_NOW: 'from now',
|
||||
NEXT_YEAR: 'next year',
|
||||
};
|
||||
|
||||
const STRUCTURAL_WORDS = [
|
||||
'at',
|
||||
'in',
|
||||
'next',
|
||||
'this',
|
||||
'from',
|
||||
'now',
|
||||
'after',
|
||||
'half',
|
||||
'same',
|
||||
'time',
|
||||
'weekend',
|
||||
'end',
|
||||
'of',
|
||||
'the',
|
||||
'eod',
|
||||
'am',
|
||||
'pm',
|
||||
];
|
||||
|
||||
const ENGLISH_VOCAB = new Set([
|
||||
...Object.keys(WEEKDAY_MAP),
|
||||
...Object.keys(MONTH_MAP),
|
||||
...Object.keys(UNIT_MAP),
|
||||
...Object.keys(WORD_NUMBER_MAP),
|
||||
...Object.keys(RELATIVE_DAY_MAP),
|
||||
...Object.keys(TIME_OF_DAY_MAP),
|
||||
...EN_WEEKDAYS_LIST,
|
||||
...EN_MONTHS_LIST,
|
||||
...STRUCTURAL_WORDS,
|
||||
]);
|
||||
const ENGLISH_VOCAB_LIST = [...ENGLISH_VOCAB];
|
||||
const hasVocabPrefix = w => ENGLISH_VOCAB_LIST.some(v => v.startsWith(w));
|
||||
|
||||
// ─── Regex for token replacement ────────────────────────────────────────────
|
||||
|
||||
const MONTH_NAMES = Object.keys(MONTH_MAP).join('|');
|
||||
const MONTH_NAME_RE = new RegExp(`\\b(?:${MONTH_NAMES})\\b`, 'i');
|
||||
const NUM_TOD_RE =
|
||||
/\b(\d{1,2}(?::\d{2})?)\s+(morning|noon|afternoon|evening|night)\b/g;
|
||||
const TOD_TO_MERIDIEM = {
|
||||
morning: 'am',
|
||||
noon: 'pm',
|
||||
afternoon: 'pm',
|
||||
evening: 'pm',
|
||||
night: 'pm',
|
||||
};
|
||||
|
||||
// ─── Translation Cache ──────────────────────────────────────────────────────
|
||||
|
||||
const safeString = v => (v == null ? '' : String(v));
|
||||
const MAX_PAIRS_CACHE = 20;
|
||||
const pairsCache = new Map();
|
||||
const CACHE_SECTIONS = [
|
||||
'UNITS',
|
||||
'RELATIVE',
|
||||
'TIME_OF_DAY',
|
||||
'WORD_NUMBERS',
|
||||
'MERIDIEM',
|
||||
];
|
||||
const SINGLE_KEYS = [
|
||||
'HALF',
|
||||
'NEXT',
|
||||
'THIS',
|
||||
'AT',
|
||||
'IN',
|
||||
'FROM_NOW',
|
||||
'NEXT_YEAR',
|
||||
];
|
||||
|
||||
/** Create a string key from translations so we can cache results. */
|
||||
const translationSignature = translations => {
|
||||
if (!translations || typeof translations !== 'object') return 'none';
|
||||
return [
|
||||
...CACHE_SECTIONS.flatMap(section => {
|
||||
const values = translations[section] || {};
|
||||
return Object.keys(values)
|
||||
.sort()
|
||||
.map(k => `${section}.${k}:${safeString(values[k]).toLowerCase()}`);
|
||||
}),
|
||||
...SINGLE_KEYS.map(
|
||||
k => `${k}:${safeString(translations[k]).toLowerCase()}`
|
||||
),
|
||||
].join('|');
|
||||
};
|
||||
|
||||
/** Build a list of [localWord, englishWord] pairs from the translations and browser locale. */
|
||||
const buildReplacementPairsUncached = (translations, locale) => {
|
||||
const pairs = [];
|
||||
const seen = new Set();
|
||||
const t = translations || {};
|
||||
|
||||
const addPair = (local, en) => {
|
||||
const l = sanitize(safeString(local));
|
||||
const e = safeString(en).toLowerCase();
|
||||
const key = `${l}\0${e}`;
|
||||
if (l && e && l !== e && !seen.has(key)) {
|
||||
seen.add(key);
|
||||
pairs.push([l, e]);
|
||||
}
|
||||
};
|
||||
|
||||
CACHE_SECTIONS.forEach(section => {
|
||||
const localSection = t[section] || {};
|
||||
const enSection = EN_DEFAULTS[section] || {};
|
||||
Object.keys(enSection).forEach(key => {
|
||||
addPair(localSection[key], enSection[key]);
|
||||
});
|
||||
});
|
||||
|
||||
SINGLE_KEYS.forEach(key => addPair(t[key], EN_DEFAULTS[key]));
|
||||
|
||||
try {
|
||||
const wdFmt = new Intl.DateTimeFormat(locale, { weekday: 'long' });
|
||||
// Jan 1, 2024 is a Monday — aligns with EN_WEEKDAYS_LIST[0]='monday'
|
||||
EN_WEEKDAYS_LIST.forEach((en, i) => {
|
||||
addPair(wdFmt.format(new Date(2024, 0, i + 1)), en);
|
||||
});
|
||||
} catch {
|
||||
/* locale not supported */
|
||||
}
|
||||
|
||||
try {
|
||||
const moFmt = new Intl.DateTimeFormat(locale, { month: 'long' });
|
||||
EN_MONTHS_LIST.forEach((en, i) => {
|
||||
addPair(moFmt.format(new Date(2024, i, 1)), en);
|
||||
});
|
||||
} catch {
|
||||
/* locale not supported */
|
||||
}
|
||||
|
||||
pairs.sort((a, b) => b[0].length - a[0].length);
|
||||
return pairs;
|
||||
};
|
||||
|
||||
/** Same as above but cached. Keeps up to 20 entries to avoid rebuilding every call. */
|
||||
const buildReplacementPairs = (translations, locale) => {
|
||||
const cacheKey = `${locale || ''}:${translationSignature(translations)}`;
|
||||
if (pairsCache.has(cacheKey)) return pairsCache.get(cacheKey);
|
||||
const pairs = buildReplacementPairsUncached(translations, locale);
|
||||
if (pairsCache.size >= MAX_PAIRS_CACHE)
|
||||
pairsCache.delete(pairsCache.keys().next().value);
|
||||
pairsCache.set(cacheKey, pairs);
|
||||
return pairs;
|
||||
};
|
||||
|
||||
// ─── Token Replacement ──────────────────────────────────────────────────────
|
||||
|
||||
const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
/** Swap localized words for their English versions in the text. */
|
||||
const substituteLocalTokens = (text, pairs) => {
|
||||
let r = text;
|
||||
pairs.forEach(([local, en]) => {
|
||||
const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g');
|
||||
r = r.replace(re, en);
|
||||
});
|
||||
return r;
|
||||
};
|
||||
|
||||
/** Drop any words the parser wouldn't understand (keeps English words and numbers). */
|
||||
const filterToEnglishVocab = text =>
|
||||
normalizeDigits(text)
|
||||
.replace(/(\d+)h\b/g, '$1:00')
|
||||
.split(/\s+/)
|
||||
.filter(w => /[\d:]/.test(w) || ENGLISH_VOCAB.has(w.toLowerCase()))
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
/** Move "next year" to the right spot so the parser can read it (after the month, before time). */
|
||||
const repositionNextYear = text => {
|
||||
if (!MONTH_NAME_RE.test(text)) return text;
|
||||
let r = text.replace(/\b(?:next\s+)?year\b/i, m =>
|
||||
/next/i.test(m) ? m : 'next year'
|
||||
);
|
||||
if (!/\bnext\s+year\b/i.test(r)) return r;
|
||||
const withoutNY = r.replace(/\bnext\s+year\b/i, '').trim();
|
||||
const timeRe = /(?:(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?)\s*$/i;
|
||||
const timePart = withoutNY.match(timeRe);
|
||||
if (timePart) {
|
||||
const beforeTime = withoutNY.slice(0, timePart.index).trim();
|
||||
r = `${beforeTime} next year ${timePart[0].trim()}`;
|
||||
} else {
|
||||
r = `${withoutNY} next year`;
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
/** Run the full translation pipeline: swap tokens, filter, fix am/pm, reposition "next year". */
|
||||
const replaceTokens = (text, pairs) => {
|
||||
const substituted = substituteLocalTokens(text, pairs);
|
||||
const filtered = filterToEnglishVocab(substituted);
|
||||
const fixed = filtered.replace(
|
||||
NUM_TOD_RE,
|
||||
(_, t, tod) => `${t}${TOD_TO_MERIDIEM[tod]}`
|
||||
);
|
||||
return stripNoise(repositionNextYear(fixed));
|
||||
};
|
||||
|
||||
/** Convert English words back to the user's language for display. */
|
||||
const reverseTokens = (text, pairs) =>
|
||||
pairs.reduce(
|
||||
(r, [local, en]) =>
|
||||
r.replace(
|
||||
new RegExp(`(?<=^|\\s)${escapeRegex(en)}(?=\\s|$)`, 'g'),
|
||||
local
|
||||
),
|
||||
text
|
||||
);
|
||||
|
||||
// ─── Main Suggestion Generator ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate snooze suggestions from what the user has typed so far.
|
||||
* Works with any language if translations are provided. Returns up to 5
|
||||
* unique results, each with a label, date, and unix timestamp.
|
||||
*
|
||||
* @param {string} text - what the user typed
|
||||
* @param {Date} [referenceDate] - treat as "now" (defaults to current time)
|
||||
* @param {{ translations?: object, locale?: string }} [options] - i18n config
|
||||
* @returns {Array<{ label: string, date: Date, unix: number }>}
|
||||
*/
|
||||
export const generateDateSuggestions = (
|
||||
text,
|
||||
referenceDate = new Date(),
|
||||
{ translations, locale } = {}
|
||||
) => {
|
||||
if (!text || typeof text !== 'string') return [];
|
||||
const normalized = sanitize(text);
|
||||
if (!normalized) return [];
|
||||
|
||||
const stripped = stripNoise(normalized);
|
||||
const pairs =
|
||||
locale && locale !== 'en'
|
||||
? buildReplacementPairs(translations, locale)
|
||||
: [];
|
||||
|
||||
// Try English first — if user types English in a non-English locale, skip translation
|
||||
const directParse = parseDateFromText(stripped, referenceDate);
|
||||
const looksEnglish =
|
||||
directParse ||
|
||||
stripped
|
||||
.split(/\s+/)
|
||||
.filter(w => !/^\d/.test(w))
|
||||
.some(w => ENGLISH_VOCAB.has(w) || (w.length >= 2 && hasVocabPrefix(w)));
|
||||
const useEnglish = !pairs.length || looksEnglish;
|
||||
|
||||
const englishInput = useEnglish ? stripped : replaceTokens(normalized, pairs);
|
||||
|
||||
const seen = new Set();
|
||||
const results = [];
|
||||
|
||||
const exact = directParse || parseDateFromText(englishInput, referenceDate);
|
||||
if (exact) {
|
||||
seen.add(exact.unix);
|
||||
results.push({ label: normalized, query: englishInput, ...exact });
|
||||
}
|
||||
|
||||
buildSuggestionCandidates(englishInput).some(candidate => {
|
||||
if (results.length >= MAX_SUGGESTIONS) return true;
|
||||
const result = parseDateFromText(candidate, referenceDate);
|
||||
if (result && !seen.has(result.unix)) {
|
||||
seen.add(result.unix);
|
||||
const label =
|
||||
!useEnglish && pairs.length
|
||||
? reverseTokens(candidate, pairs)
|
||||
: candidate;
|
||||
results.push({ label, query: candidate, ...result });
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
@@ -1,676 +0,0 @@
|
||||
/**
|
||||
* Parses natural language text into a future date.
|
||||
*
|
||||
* Flow: clean the input → try each matcher in order → return the first future date.
|
||||
* The MATCHERS order matters — see the comment above the array.
|
||||
*/
|
||||
|
||||
import {
|
||||
add,
|
||||
startOfDay,
|
||||
getDay,
|
||||
isSaturday,
|
||||
isSunday,
|
||||
nextFriday,
|
||||
nextSaturday,
|
||||
getUnixTime,
|
||||
isValid,
|
||||
startOfWeek,
|
||||
addWeeks,
|
||||
isAfter,
|
||||
isBefore,
|
||||
endOfMonth,
|
||||
} from 'date-fns';
|
||||
|
||||
import {
|
||||
WEEKDAY_MAP,
|
||||
MONTH_MAP,
|
||||
RELATIVE_DAY_MAP,
|
||||
UNIT_MAP,
|
||||
WORD_NUMBER_MAP,
|
||||
NEXT_WEEKDAY_FN,
|
||||
TIME_OF_DAY_MAP,
|
||||
TOD_HOUR_RANGE,
|
||||
HALF_UNIT_DURATIONS,
|
||||
sanitize,
|
||||
stripNoise,
|
||||
parseNumber,
|
||||
parseTimeString,
|
||||
applyTimeToDate,
|
||||
applyTimeOrDefault,
|
||||
strictDate,
|
||||
futureOrNextYear,
|
||||
ensureFutureOrNextDay,
|
||||
inferHoursFromTOD,
|
||||
addFractionalSafe,
|
||||
} from './tokenMaps';
|
||||
|
||||
// ─── Regex Fragments (derived from maps) ────────────────────────────────────
|
||||
|
||||
const WEEKDAY_NAMES = Object.keys(WEEKDAY_MAP).join('|');
|
||||
const MONTH_NAMES = Object.keys(MONTH_MAP).join('|');
|
||||
const UNIT_NAMES = Object.keys(UNIT_MAP).join('|');
|
||||
const WORD_NUMBERS = Object.keys(WORD_NUMBER_MAP).join('|');
|
||||
const RELATIVE_DAYS = Object.keys(RELATIVE_DAY_MAP).join('|');
|
||||
const TIME_OF_DAY_NAMES = 'morning|afternoon|evening|night|noon|midnight';
|
||||
|
||||
const NUM_RE = `(\\d+(?:\\.5)?|${WORD_NUMBERS})`;
|
||||
const UNIT_RE = `(${UNIT_NAMES})`;
|
||||
const TIME_SUFFIX_RE =
|
||||
'(?:\\s+(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?)?|\\d{1,2}:\\d{2}))?';
|
||||
|
||||
// ─── Pre-compiled Regexes ───────────────────────────────────────────────────
|
||||
|
||||
const HALF_UNIT_RE = /^(?:in\s+)?half\s+(?:an?\s+)?(hour|day|week|month|year)$/;
|
||||
const RELATIVE_DURATION_RE = new RegExp(`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}$`);
|
||||
const DURATION_FROM_NOW_RE = new RegExp(
|
||||
`^${NUM_RE}\\s+${UNIT_RE}\\s+from\\s+now$`
|
||||
);
|
||||
const RELATIVE_DAY_ONLY_RE = new RegExp(`^(${RELATIVE_DAYS})$`);
|
||||
const RELATIVE_DAY_TOD_RE = new RegExp(
|
||||
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})$`
|
||||
);
|
||||
const RELATIVE_DAY_TOD_TIME_RE = new RegExp(
|
||||
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
|
||||
);
|
||||
const RELATIVE_DAY_AT_TIME_RE = new RegExp(
|
||||
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?` +
|
||||
'(\\d{1,2}(?::\\d{2})?\\s*' +
|
||||
'(?:am|pm|a\\.m\\.?|p\\.m\\.?)?|\\d{1,2}:\\d{2})$'
|
||||
);
|
||||
const RELATIVE_DAY_SAME_TIME_RE = new RegExp(
|
||||
`^(${RELATIVE_DAYS})\\s+(?:same\\s+time|this\\s+time)$`
|
||||
);
|
||||
const NEXT_UNIT_RE = new RegExp(
|
||||
`^next\\s+(hour|minute|week|month|year)${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
const NEXT_MONTH_RE = new RegExp(`^next\\s+(${MONTH_NAMES})${TIME_SUFFIX_RE}$`);
|
||||
const NEXT_WEEKDAY_TOD_RE = new RegExp(
|
||||
`^next\\s+(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})$`
|
||||
);
|
||||
const NEXT_WEEKDAY_RE = new RegExp(
|
||||
`^(?:(${WEEKDAY_NAMES})\\s+(?:of\\s+)?next\\s+week` +
|
||||
`|next\\s+week\\s+(${WEEKDAY_NAMES})` +
|
||||
`|next\\s+(${WEEKDAY_NAMES}))${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
const SAME_TIME_WEEKDAY_RE = new RegExp(
|
||||
`^(?:same\\s+time|this\\s+time)\\s+(${WEEKDAY_NAMES})$`
|
||||
);
|
||||
const WEEKDAY_TOD_RE = new RegExp(
|
||||
`^(?:(?:this|upcoming|coming)\\s+)?` +
|
||||
`(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})$`
|
||||
);
|
||||
const WEEKDAY_TOD_TIME_RE = new RegExp(
|
||||
`^(?:(?:this|upcoming|coming)\\s+)?` +
|
||||
`(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
|
||||
);
|
||||
const WEEKDAY_TIME_RE = new RegExp(
|
||||
`^(?:(?:this|upcoming|coming)\\s+)?(${WEEKDAY_NAMES})${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
const TIME_ONLY_MERIDIEM_RE =
|
||||
/^(?:at\s+)?(\d{1,2}(?::\d{2})?\s*(?:am|pm|a\.m\.?|p\.m\.?))$/;
|
||||
const TIME_ONLY_24H_RE = /^(?:at\s+)?(\d{1,2}:\d{2})$/;
|
||||
const TOD_WITH_TIME_RE = new RegExp(
|
||||
`^(?:(?:this|the)\\s+)?(${TIME_OF_DAY_NAMES})\\s+` +
|
||||
'(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*' +
|
||||
'(?:am|pm|a\\.m\\.?|p\\.m\\.?)?)$'
|
||||
);
|
||||
const TOD_PLAIN_RE = new RegExp(
|
||||
'(?:(?:later|in)\\s+)?(?:(?:this|the)\\s+)?' +
|
||||
`(?:${TIME_OF_DAY_NAMES}|eod|end of day|end of the day)$`
|
||||
);
|
||||
const ABSOLUTE_DATE_RE = new RegExp(
|
||||
`^(${MONTH_NAMES})\\s+(\\d{1,2})(?:st|nd|rd|th)?` +
|
||||
`(?:[,\\s]+(\\d{4}|next\\s+year))?${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
const ABSOLUTE_DATE_REVERSED_RE = new RegExp(
|
||||
`^(\\d{1,2})(?:st|nd|rd|th)?\\s+(${MONTH_NAMES})` +
|
||||
`(?:[,\\s]+(\\d{4}|next\\s+year))?${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
const MONTH_YEAR_RE = new RegExp(`^(${MONTH_NAMES})\\s+(\\d{4})$`);
|
||||
const DAY_AFTER_TOMORROW_RE = new RegExp(
|
||||
`^day\\s+after\\s+tomorrow${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
|
||||
const COMPOUND_DURATION_RE = new RegExp(
|
||||
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+(?:and\\s+)?${NUM_RE}\\s+${UNIT_RE}$`
|
||||
);
|
||||
const DURATION_AT_TIME_RE = new RegExp(
|
||||
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+at\\s+` +
|
||||
'(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?)?)$'
|
||||
);
|
||||
const END_OF_RE = /^end\s+of\s+(?:the\s+)?(week|month|day)$/;
|
||||
const LATER_TODAY_RE = /^later\s+(?:today|this\s+(?:afternoon|evening))$/;
|
||||
|
||||
const TIME_SUFFIX_COMPILED = new RegExp(`${TIME_SUFFIX_RE}$`);
|
||||
const ISO_DATE_RE = new RegExp(
|
||||
`^(\\d{4})-(\\d{1,2})-(\\d{1,2})${TIME_SUFFIX_COMPILED.source}`
|
||||
);
|
||||
const SLASH_DATE_RE = new RegExp(
|
||||
`^(\\d{1,2})/(\\d{1,2})/(\\d{4})${TIME_SUFFIX_COMPILED.source}`
|
||||
);
|
||||
const DASH_DATE_RE = new RegExp(
|
||||
`^(\\d{1,2})-(\\d{1,2})-(\\d{4})${TIME_SUFFIX_COMPILED.source}`
|
||||
);
|
||||
const DOT_DATE_RE = new RegExp(
|
||||
`^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})${TIME_SUFFIX_COMPILED.source}`
|
||||
);
|
||||
const AMBIGUOUS_DATE_RES = [SLASH_DATE_RE, DASH_DATE_RE, DOT_DATE_RE];
|
||||
|
||||
// ─── Pattern Matchers ───────────────────────────────────────────────────────
|
||||
|
||||
/** Read amount and unit from a regex match, then add to now. */
|
||||
const parseDuration = (match, now) => {
|
||||
if (!match) return null;
|
||||
const amount = parseNumber(match[1]);
|
||||
const unit = UNIT_MAP[match[2]];
|
||||
if (amount == null || !unit) return null;
|
||||
return addFractionalSafe(now, unit, amount);
|
||||
};
|
||||
|
||||
/** Handle "in 2 hours", "half day", "3h30m", "5 min from now". */
|
||||
const matchDuration = (text, now) => {
|
||||
const half = text.match(HALF_UNIT_RE);
|
||||
if (half) {
|
||||
return HALF_UNIT_DURATIONS[half[1]]
|
||||
? add(now, HALF_UNIT_DURATIONS[half[1]])
|
||||
: null;
|
||||
}
|
||||
|
||||
const compound = text.match(COMPOUND_DURATION_RE);
|
||||
if (compound) {
|
||||
const a1 = parseNumber(compound[1]);
|
||||
const u1 = UNIT_MAP[compound[2]];
|
||||
const a2 = parseNumber(compound[3]);
|
||||
const u2 = UNIT_MAP[compound[4]];
|
||||
if (a1 == null || !u1 || a2 == null || !u2) {
|
||||
return null;
|
||||
}
|
||||
return add(add(now, { [u1]: a1 }), { [u2]: a2 });
|
||||
}
|
||||
|
||||
const atTime = text.match(DURATION_AT_TIME_RE);
|
||||
if (atTime) {
|
||||
const amount = parseNumber(atTime[1]);
|
||||
const unit = UNIT_MAP[atTime[2]];
|
||||
const time = parseTimeString(atTime[3]);
|
||||
if (amount == null || !unit || !time) {
|
||||
return null;
|
||||
}
|
||||
return applyTimeToDate(
|
||||
add(now, { [unit]: amount }),
|
||||
time.hours,
|
||||
time.minutes
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
parseDuration(text.match(DURATION_FROM_NOW_RE), now) ||
|
||||
parseDuration(text.match(RELATIVE_DURATION_RE), now)
|
||||
);
|
||||
};
|
||||
|
||||
/** Set time on a day offset. If the result is already past, move to the next day. */
|
||||
const applyTimeWithRollover = (offset, hours, minutes, now) => {
|
||||
const base = add(startOfDay(now), { days: offset });
|
||||
const date = applyTimeToDate(base, hours, minutes);
|
||||
if (isAfter(date, now)) return date;
|
||||
return applyTimeToDate(add(base, { days: 1 }), hours, minutes);
|
||||
};
|
||||
|
||||
/** Handle "today", "tonight", "tomorrow" with optional time. */
|
||||
const matchRelativeDay = (text, now) => {
|
||||
const dayOnlyMatch = text.match(RELATIVE_DAY_ONLY_RE);
|
||||
if (dayOnlyMatch) {
|
||||
const key = dayOnlyMatch[1];
|
||||
const offset = RELATIVE_DAY_MAP[key];
|
||||
if (key === 'tonight' || key === 'tonite') {
|
||||
return ensureFutureOrNextDay(
|
||||
applyTimeToDate(add(startOfDay(now), { days: offset }), 20, 0),
|
||||
now
|
||||
);
|
||||
}
|
||||
if (offset === 1) {
|
||||
return applyTimeToDate(add(startOfDay(now), { days: 1 }), 9, 0);
|
||||
}
|
||||
return add(now, { hours: 1 });
|
||||
}
|
||||
|
||||
const dayTodTimeMatch = text.match(RELATIVE_DAY_TOD_TIME_RE);
|
||||
if (dayTodTimeMatch) {
|
||||
const timeParts = dayTodTimeMatch[3].split(':');
|
||||
const time = inferHoursFromTOD(
|
||||
dayTodTimeMatch[2],
|
||||
timeParts[0],
|
||||
timeParts[1]
|
||||
);
|
||||
if (!time) return null;
|
||||
return applyTimeWithRollover(
|
||||
RELATIVE_DAY_MAP[dayTodTimeMatch[1]],
|
||||
time.hours,
|
||||
time.minutes,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const dayTodMatch = text.match(RELATIVE_DAY_TOD_RE);
|
||||
if (dayTodMatch) {
|
||||
const { hours, minutes } = TIME_OF_DAY_MAP[dayTodMatch[2]];
|
||||
return applyTimeWithRollover(
|
||||
RELATIVE_DAY_MAP[dayTodMatch[1]],
|
||||
hours,
|
||||
minutes,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const dayAtTimeMatch = text.match(RELATIVE_DAY_AT_TIME_RE);
|
||||
if (dayAtTimeMatch) {
|
||||
const [, dayKey, timeRaw] = dayAtTimeMatch;
|
||||
const bare = /^(tonight|tonite)$/.test(dayKey) && !/[ap]m/i.test(timeRaw);
|
||||
const time = bare
|
||||
? inferHoursFromTOD('tonight', ...timeRaw.split(':'))
|
||||
: parseTimeString(timeRaw);
|
||||
if (!time) return null;
|
||||
return applyTimeWithRollover(
|
||||
RELATIVE_DAY_MAP[dayKey],
|
||||
time.hours,
|
||||
time.minutes,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const sameTimeMatch = text.match(RELATIVE_DAY_SAME_TIME_RE);
|
||||
if (sameTimeMatch) {
|
||||
const offset = RELATIVE_DAY_MAP[sameTimeMatch[1]];
|
||||
if (offset <= 0) return null;
|
||||
return applyTimeToDate(
|
||||
add(startOfDay(now), { days: offset }),
|
||||
now.getHours(),
|
||||
now.getMinutes()
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Find the given weekday in next week (not this week). */
|
||||
const nextWeekdayInNextWeek = (dayIndex, now) => {
|
||||
const fn = NEXT_WEEKDAY_FN[dayIndex];
|
||||
if (!fn) return null;
|
||||
const date = fn(now);
|
||||
const sameWeek =
|
||||
startOfWeek(now, { weekStartsOn: 1 }).getTime() ===
|
||||
startOfWeek(date, { weekStartsOn: 1 }).getTime();
|
||||
return sameWeek ? fn(date) : date;
|
||||
};
|
||||
|
||||
/** Handle "next friday", "next week", "next month", "next january", etc. */
|
||||
const matchNextPattern = (text, now) => {
|
||||
const nextUnitMatch = text.match(NEXT_UNIT_RE);
|
||||
if (nextUnitMatch) {
|
||||
const unit = nextUnitMatch[1];
|
||||
if (unit === 'hour') return add(now, { hours: 1 });
|
||||
if (unit === 'minute') return add(now, { minutes: 1 });
|
||||
if (unit === 'week') {
|
||||
const base = startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 });
|
||||
return applyTimeOrDefault(base, nextUnitMatch[2]);
|
||||
}
|
||||
const base = add(startOfDay(now), { [`${unit}s`]: 1 });
|
||||
return applyTimeOrDefault(base, nextUnitMatch[2]);
|
||||
}
|
||||
|
||||
const nextMonthMatch = text.match(NEXT_MONTH_RE);
|
||||
if (nextMonthMatch) {
|
||||
const monthIdx = MONTH_MAP[nextMonthMatch[1]];
|
||||
let year = now.getFullYear();
|
||||
if (monthIdx <= now.getMonth()) year += 1;
|
||||
const base = new Date(year, monthIdx, 1);
|
||||
return applyTimeOrDefault(base, nextMonthMatch[2]);
|
||||
}
|
||||
|
||||
// "next monday morning", "next friday midnight" — weekday + time-of-day
|
||||
const nextTodMatch = text.match(NEXT_WEEKDAY_TOD_RE);
|
||||
if (nextTodMatch) {
|
||||
const date = nextWeekdayInNextWeek(WEEKDAY_MAP[nextTodMatch[1]], now);
|
||||
if (!date) return null;
|
||||
const { hours, minutes } = TIME_OF_DAY_MAP[nextTodMatch[2]];
|
||||
return applyTimeToDate(date, hours, minutes);
|
||||
}
|
||||
|
||||
// "monday of next week", "next week monday", "next friday" — all with optional time
|
||||
const weekdayMatch = text.match(NEXT_WEEKDAY_RE);
|
||||
if (weekdayMatch) {
|
||||
const dayName = weekdayMatch[1] || weekdayMatch[2] || weekdayMatch[3];
|
||||
const date = nextWeekdayInNextWeek(WEEKDAY_MAP[dayName], now);
|
||||
if (!date) return null;
|
||||
return applyTimeOrDefault(date, weekdayMatch[4]);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Find the next occurrence of a weekday, with optional time. */
|
||||
const resolveWeekdayDate = (dayIndex, timeStr, now) => {
|
||||
const fn = NEXT_WEEKDAY_FN[dayIndex];
|
||||
if (!fn) return null;
|
||||
let adjusted = timeStr;
|
||||
if (timeStr && /^\d{1,2}$/.test(timeStr.trim())) {
|
||||
const h = parseInt(timeStr, 10);
|
||||
if (h >= 1 && h <= 7) adjusted = `${h}pm`;
|
||||
}
|
||||
|
||||
if (getDay(now) === dayIndex) {
|
||||
const todayDate = applyTimeOrDefault(now, adjusted);
|
||||
if (todayDate && isAfter(todayDate, now)) return todayDate;
|
||||
}
|
||||
|
||||
return applyTimeOrDefault(fn(now), adjusted);
|
||||
};
|
||||
|
||||
/** Handle "friday", "monday 3pm", "wed morning", "same time friday". */
|
||||
const matchWeekday = (text, now) => {
|
||||
const sameTimeWeekday = text.match(SAME_TIME_WEEKDAY_RE);
|
||||
if (sameTimeWeekday) {
|
||||
const dayIndex = WEEKDAY_MAP[sameTimeWeekday[1]];
|
||||
const fn = NEXT_WEEKDAY_FN[dayIndex];
|
||||
if (!fn) return null;
|
||||
const target = fn(now);
|
||||
return applyTimeToDate(target, now.getHours(), now.getMinutes());
|
||||
}
|
||||
|
||||
// "monday morning 6", "friday evening 7" — weekday + tod + bare number
|
||||
const todTimeMatch = text.match(WEEKDAY_TOD_TIME_RE);
|
||||
if (todTimeMatch) {
|
||||
const dayIndex = WEEKDAY_MAP[todTimeMatch[1]];
|
||||
const fn = NEXT_WEEKDAY_FN[dayIndex];
|
||||
if (!fn) return null;
|
||||
const timeParts = todTimeMatch[3].split(':');
|
||||
const time = inferHoursFromTOD(todTimeMatch[2], timeParts[0], timeParts[1]);
|
||||
if (!time) return null;
|
||||
const target =
|
||||
getDay(now) === dayIndex ? startOfDay(now) : startOfDay(fn(now));
|
||||
const date = applyTimeToDate(target, time.hours, time.minutes);
|
||||
return isAfter(date, now)
|
||||
? date
|
||||
: applyTimeToDate(fn(now), time.hours, time.minutes);
|
||||
}
|
||||
|
||||
// "monday morning", "friday midnight", "wednesday evening", etc.
|
||||
const todMatch = text.match(WEEKDAY_TOD_RE);
|
||||
if (todMatch) {
|
||||
const dayIndex = WEEKDAY_MAP[todMatch[1]];
|
||||
const fn = NEXT_WEEKDAY_FN[dayIndex];
|
||||
if (!fn) return null;
|
||||
const { hours, minutes } = TIME_OF_DAY_MAP[todMatch[2]];
|
||||
const target =
|
||||
getDay(now) === dayIndex ? startOfDay(now) : startOfDay(fn(now));
|
||||
const date = applyTimeToDate(target, hours, minutes);
|
||||
return isAfter(date, now) ? date : applyTimeToDate(fn(now), hours, minutes);
|
||||
}
|
||||
|
||||
const match = text.match(WEEKDAY_TIME_RE);
|
||||
if (!match) return null;
|
||||
|
||||
return resolveWeekdayDate(WEEKDAY_MAP[match[1]], match[2], now);
|
||||
};
|
||||
|
||||
/** Handle a standalone time like "3pm", "14:30", "at 9am". */
|
||||
const matchTimeOnly = (text, now) => {
|
||||
const match =
|
||||
text.match(TIME_ONLY_MERIDIEM_RE) || text.match(TIME_ONLY_24H_RE);
|
||||
if (!match) return null;
|
||||
|
||||
const time = parseTimeString(match[1]);
|
||||
if (!time) return null;
|
||||
return ensureFutureOrNextDay(
|
||||
applyTimeToDate(now, time.hours, time.minutes),
|
||||
now
|
||||
);
|
||||
};
|
||||
|
||||
/** Handle "morning", "evening 6pm", "eod", "this afternoon". */
|
||||
const matchTimeOfDay = (text, now) => {
|
||||
const todWithTime = text.match(TOD_WITH_TIME_RE);
|
||||
if (todWithTime) {
|
||||
const rawTime = todWithTime[2].trim();
|
||||
const hasMeridiem = /(?:am|pm|a\.m|p\.m)/i.test(rawTime);
|
||||
let time;
|
||||
if (hasMeridiem) {
|
||||
time = parseTimeString(rawTime);
|
||||
const range = TOD_HOUR_RANGE[todWithTime[1]];
|
||||
if (!time) return null;
|
||||
if (range) {
|
||||
const h = time.hours === 0 ? 24 : time.hours;
|
||||
if (h < range[0] || h >= range[1]) return null;
|
||||
}
|
||||
} else {
|
||||
const parts = rawTime.split(':');
|
||||
time = inferHoursFromTOD(todWithTime[1], parts[0], parts[1]);
|
||||
}
|
||||
if (!time) return null;
|
||||
return ensureFutureOrNextDay(
|
||||
applyTimeToDate(now, time.hours, time.minutes),
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const match = text.match(TOD_PLAIN_RE);
|
||||
if (!match) return null;
|
||||
|
||||
const key = text
|
||||
.replace(/^(?:later|in)\s+/, '')
|
||||
.replace(/^(?:this|the)\s+/, '')
|
||||
.trim();
|
||||
const tod = TIME_OF_DAY_MAP[key];
|
||||
if (!tod) return null;
|
||||
return ensureFutureOrNextDay(
|
||||
applyTimeToDate(now, tod.hours, tod.minutes),
|
||||
now
|
||||
);
|
||||
};
|
||||
|
||||
/** Turn month + day + optional year into a future date. */
|
||||
const resolveAbsoluteDate = (month, day, yearStr, timeStr, now) => {
|
||||
let year = now.getFullYear();
|
||||
if (yearStr && /next\s+year/i.test(yearStr)) {
|
||||
year += 1;
|
||||
} else if (yearStr) {
|
||||
year = parseInt(yearStr, 10);
|
||||
}
|
||||
if (yearStr) {
|
||||
const base = strictDate(year, month, day);
|
||||
if (!base) return null;
|
||||
const date = applyTimeOrDefault(base, timeStr);
|
||||
return date && isAfter(date, now) ? date : null;
|
||||
}
|
||||
return futureOrNextYear(year, month, day, timeStr, now);
|
||||
};
|
||||
|
||||
/** Handle "jan 15", "15 march", "december 2025". */
|
||||
const matchNamedDate = (text, now) => {
|
||||
const abs = text.match(ABSOLUTE_DATE_RE);
|
||||
if (abs) {
|
||||
return resolveAbsoluteDate(
|
||||
MONTH_MAP[abs[1]],
|
||||
parseInt(abs[2], 10),
|
||||
abs[3],
|
||||
abs[4],
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const rev = text.match(ABSOLUTE_DATE_REVERSED_RE);
|
||||
if (rev) {
|
||||
return resolveAbsoluteDate(
|
||||
MONTH_MAP[rev[2]],
|
||||
parseInt(rev[1], 10),
|
||||
rev[3],
|
||||
rev[4],
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const my = text.match(MONTH_YEAR_RE);
|
||||
if (my) {
|
||||
const date = new Date(parseInt(my[2], 10), MONTH_MAP[my[1]], 1);
|
||||
if (!isValid(date)) return null;
|
||||
const result = applyTimeToDate(date, 9, 0);
|
||||
return isAfter(result, now) ? result : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Build a date from year/month/day numbers, with optional time. */
|
||||
const buildDateWithOptionalTime = (year, month, day, timeStr) => {
|
||||
const date = strictDate(year, month, day);
|
||||
if (!date) return null;
|
||||
return applyTimeOrDefault(date, timeStr);
|
||||
};
|
||||
|
||||
// When both values are ≤ 12 (ambiguous), defaults to M/D (US format).
|
||||
const disambiguateDayMonth = (a, b) => {
|
||||
if (a > 12) return { day: a, month: b - 1 };
|
||||
if (b > 12) return { month: a - 1, day: b };
|
||||
return { month: a - 1, day: b };
|
||||
};
|
||||
|
||||
/** Handle formal dates: "2025-01-15", "1/15/2025", "15.01.2025". */
|
||||
const matchFormalDate = (text, now) => {
|
||||
const ensureFuture = date => (date && isAfter(date, now) ? date : null);
|
||||
|
||||
const isoMatch = text.match(ISO_DATE_RE);
|
||||
if (isoMatch) {
|
||||
return ensureFuture(
|
||||
buildDateWithOptionalTime(
|
||||
parseInt(isoMatch[1], 10),
|
||||
parseInt(isoMatch[2], 10) - 1,
|
||||
parseInt(isoMatch[3], 10),
|
||||
isoMatch[4]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let result = null;
|
||||
AMBIGUOUS_DATE_RES.some(re => {
|
||||
const m = text.match(re);
|
||||
if (!m) return false;
|
||||
const { month, day } = disambiguateDayMonth(
|
||||
parseInt(m[1], 10),
|
||||
parseInt(m[2], 10)
|
||||
);
|
||||
result = ensureFuture(
|
||||
buildDateWithOptionalTime(parseInt(m[3], 10), month, day, m[4])
|
||||
);
|
||||
return true;
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
/** Handle "day after tomorrow", "end of week", "this weekend", "later today". */
|
||||
const matchSpecial = (text, now) => {
|
||||
const dat = text.match(DAY_AFTER_TOMORROW_RE);
|
||||
if (dat) return applyTimeOrDefault(add(startOfDay(now), { days: 2 }), dat[1]);
|
||||
|
||||
const eof = text.match(END_OF_RE);
|
||||
if (eof) {
|
||||
if (eof[1] === 'day') return applyTimeToDate(now, 17, 0);
|
||||
if (eof[1] === 'week') {
|
||||
const fri = applyTimeToDate(now, 17, 0);
|
||||
if (getDay(now) === 5 && isAfter(fri, now)) return fri;
|
||||
return applyTimeToDate(nextFriday(now), 17, 0);
|
||||
}
|
||||
if (eof[1] === 'month') {
|
||||
const eom = applyTimeToDate(endOfMonth(now), 17, 0);
|
||||
if (isAfter(eom, now)) return eom;
|
||||
return applyTimeToDate(endOfMonth(add(now, { months: 1 })), 17, 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (LATER_TODAY_RE.test(text)) return add(now, { hours: 3 });
|
||||
|
||||
const weekendMatch = text.match(
|
||||
/^(this weekend|weekend|next weekend)(?:\s+(?:at\s+)?(.+))?$/
|
||||
);
|
||||
if (weekendMatch) {
|
||||
const isNext = weekendMatch[1] === 'next weekend';
|
||||
const timeStr = weekendMatch[2];
|
||||
|
||||
if (isNext) {
|
||||
const sat = nextSaturday(now);
|
||||
const d = isSaturday(now) || isSunday(now) ? sat : add(sat, { weeks: 1 });
|
||||
return applyTimeOrDefault(d, timeStr);
|
||||
}
|
||||
|
||||
if (isSaturday(now)) {
|
||||
if (!timeStr) {
|
||||
if (now.getHours() < 10) return applyTimeToDate(now, 10, 0);
|
||||
if (now.getHours() < 18) return add(now, { hours: 2 });
|
||||
return applyTimeToDate(add(startOfDay(now), { days: 1 }), 10, 0);
|
||||
}
|
||||
const today = applyTimeOrDefault(now, timeStr);
|
||||
if (today && isAfter(today, now)) return today;
|
||||
return applyTimeOrDefault(add(startOfDay(now), { days: 1 }), timeStr);
|
||||
}
|
||||
if (isSunday(now)) {
|
||||
if (!timeStr) {
|
||||
if (now.getHours() < 10) return applyTimeToDate(now, 10, 0);
|
||||
return add(now, { hours: 2 });
|
||||
}
|
||||
const today = applyTimeOrDefault(now, timeStr);
|
||||
if (today && isAfter(today, now)) return today;
|
||||
}
|
||||
return applyTimeOrDefault(nextSaturday(now), timeStr);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// ─── Main Parser ────────────────────────────────────────────────────────────
|
||||
|
||||
// Order matters — first match wins. Common patterns go first.
|
||||
// Do not reorder without running the spec.
|
||||
const MATCHERS = [
|
||||
matchDuration, // "in 2 hours", "half day", "3h30m"
|
||||
matchSpecial, // "end of week", "later today", "this weekend"
|
||||
matchRelativeDay, // "tomorrow 3pm", "tonight", "today morning"
|
||||
matchNextPattern, // "next friday", "next week", "next month"
|
||||
matchTimeOfDay, // "morning", "evening 6pm", "eod"
|
||||
matchWeekday, // "friday", "monday 3pm", "wed morning"
|
||||
matchTimeOnly, // "3pm", "14:30" (must be after weekday to avoid conflicts)
|
||||
matchNamedDate, // "jan 15", "march 20 next year"
|
||||
matchFormalDate, // "2025-01-15", "1/15/2025" (least common, last)
|
||||
];
|
||||
|
||||
/**
|
||||
* Parse free-form text into a future date.
|
||||
* Returns { date, unix } or null. Only returns dates after referenceDate.
|
||||
*
|
||||
* @param {string} text - user input like "in 2 hours" or "next friday 3pm"
|
||||
* @param {Date} [referenceDate] - treat as "now" (defaults to current time)
|
||||
* @returns {{ date: Date, unix: number } | null}
|
||||
*/
|
||||
export const parseDateFromText = (text, referenceDate = new Date()) => {
|
||||
if (!text || typeof text !== 'string') return null;
|
||||
|
||||
const normalized = stripNoise(sanitize(text));
|
||||
if (!normalized) return null;
|
||||
|
||||
const maxDate = add(referenceDate, { years: 999 });
|
||||
|
||||
const isValidFuture = d =>
|
||||
d && isValid(d) && isAfter(d, referenceDate) && !isBefore(maxDate, d);
|
||||
|
||||
let result = null;
|
||||
MATCHERS.some(matcher => {
|
||||
const d = matcher(normalized, referenceDate);
|
||||
if (isValidFuture(d)) {
|
||||
result = { date: d, unix: getUnixTime(d) };
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* Builds autocomplete suggestions as the user types a snooze date.
|
||||
* Matches partial input against known phrases and ranks them by closeness.
|
||||
*/
|
||||
|
||||
import {
|
||||
UNIT_MAP,
|
||||
WEEKDAY_MAP,
|
||||
TIME_OF_DAY_MAP,
|
||||
RELATIVE_DAY_MAP,
|
||||
WORD_NUMBER_MAP,
|
||||
MONTH_MAP,
|
||||
HALF_UNIT_DURATIONS,
|
||||
} from './tokenMaps';
|
||||
|
||||
const SUGGESTION_UNITS = [...new Set(Object.values(UNIT_MAP))].filter(
|
||||
u => u !== 'seconds'
|
||||
);
|
||||
|
||||
const FULL_WEEKDAYS = Object.keys(WEEKDAY_MAP).filter(k => k.length > 3);
|
||||
const TOD_NAMES = Object.keys(TIME_OF_DAY_MAP).filter(k => !k.includes(' '));
|
||||
const MONTH_NAMES_LONG = Object.keys(MONTH_MAP).filter(k => k.length > 3);
|
||||
|
||||
const ALL_SUGGESTION_PHRASES = [
|
||||
...Object.keys(RELATIVE_DAY_MAP),
|
||||
...FULL_WEEKDAYS,
|
||||
...TOD_NAMES,
|
||||
'next week',
|
||||
'next month',
|
||||
'this weekend',
|
||||
'next weekend',
|
||||
'day after tomorrow',
|
||||
'later today',
|
||||
'end of day',
|
||||
'end of week',
|
||||
'end of month',
|
||||
...TOD_NAMES.map(tod => `tomorrow ${tod}`),
|
||||
...TOD_NAMES.map(tod => `tomorrow at ${tod}`),
|
||||
...FULL_WEEKDAYS.map(wd => `next ${wd}`),
|
||||
...FULL_WEEKDAYS.map(wd => `this ${wd}`),
|
||||
...FULL_WEEKDAYS.flatMap(wd => TOD_NAMES.map(tod => `${wd} ${tod}`)),
|
||||
...FULL_WEEKDAYS.flatMap(wd => TOD_NAMES.map(tod => `next ${wd} ${tod}`)),
|
||||
...MONTH_NAMES_LONG.map(m => `${m} 1`),
|
||||
];
|
||||
|
||||
/** Check how closely the input matches a candidate. -1 = no match, 0 = exact prefix, N = extra words needed. */
|
||||
const prefixMatchScore = (candidate, input) => {
|
||||
if (candidate === input) return -1;
|
||||
if (candidate.startsWith(input)) return 0;
|
||||
const inputWords = input.split(' ');
|
||||
const candidateWords = candidate.split(' ');
|
||||
const lastIdx = inputWords.reduce((prev, iw) => {
|
||||
if (prev === -2) return -2;
|
||||
const idx = candidateWords.findIndex(
|
||||
(cw, ci) => ci > prev && cw.startsWith(iw)
|
||||
);
|
||||
return idx === -1 ? -2 : idx;
|
||||
}, -1);
|
||||
if (lastIdx === -2) return -1;
|
||||
return candidateWords.length - inputWords.length;
|
||||
};
|
||||
|
||||
export const MAX_SUGGESTIONS = 5;
|
||||
|
||||
/** Turn user input into a ranked list of suggestion strings to try parsing. */
|
||||
export const buildSuggestionCandidates = text => {
|
||||
if (!text) return [];
|
||||
|
||||
if (/^\d/.test(text)) {
|
||||
const num = text.match(/^\d+(?:\.5)?/)[0];
|
||||
const candidates = SUGGESTION_UNITS.map(u => `${num} ${u}`);
|
||||
const trimmed = text.replace(/\s+/g, ' ').trim();
|
||||
return trimmed.length > num.length
|
||||
? candidates.filter(c => c.startsWith(trimmed))
|
||||
: candidates;
|
||||
}
|
||||
|
||||
if (text.length >= 2 && 'half'.startsWith(text)) {
|
||||
return Object.keys(HALF_UNIT_DURATIONS).map(u => `half ${u}`);
|
||||
}
|
||||
|
||||
const wordNum = WORD_NUMBER_MAP[text];
|
||||
if (wordNum != null && wordNum >= 1) {
|
||||
return SUGGESTION_UNITS.map(u => `${wordNum} ${u}`);
|
||||
}
|
||||
|
||||
const scored = ALL_SUGGESTION_PHRASES.reduce((acc, candidate) => {
|
||||
const score = prefixMatchScore(candidate, text);
|
||||
if (score >= 0) acc.push({ candidate, score });
|
||||
return acc;
|
||||
}, []);
|
||||
scored.sort((a, b) => a.score - b.score);
|
||||
const seen = new Set();
|
||||
return scored.reduce((acc, { candidate }) => {
|
||||
if (acc.length < MAX_SUGGESTIONS * 3 && !seen.has(candidate)) {
|
||||
seen.add(candidate);
|
||||
acc.push(candidate);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
@@ -1,375 +0,0 @@
|
||||
/**
|
||||
* Shared lookup tables and helper functions used by the parser,
|
||||
* suggestions, and localization modules.
|
||||
*/
|
||||
|
||||
import {
|
||||
add,
|
||||
set,
|
||||
isValid,
|
||||
isAfter,
|
||||
nextMonday,
|
||||
nextTuesday,
|
||||
nextWednesday,
|
||||
nextThursday,
|
||||
nextFriday,
|
||||
nextSaturday,
|
||||
nextSunday,
|
||||
} from 'date-fns';
|
||||
|
||||
// ─── Token Maps ──────────────────────────────────────────────────────────────
|
||||
// All keys are lowercase. Short forms and full names both work.
|
||||
|
||||
/** Weekday name or short form → day index (0 = Sunday). */
|
||||
export const WEEKDAY_MAP = {
|
||||
sunday: 0,
|
||||
sun: 0,
|
||||
monday: 1,
|
||||
mon: 1,
|
||||
tuesday: 2,
|
||||
tue: 2,
|
||||
tues: 2,
|
||||
wednesday: 3,
|
||||
wed: 3,
|
||||
thursday: 4,
|
||||
thu: 4,
|
||||
thur: 4,
|
||||
thurs: 4,
|
||||
friday: 5,
|
||||
fri: 5,
|
||||
saturday: 6,
|
||||
sat: 6,
|
||||
};
|
||||
|
||||
/** Month name or short form → month index (0 = January). */
|
||||
export const MONTH_MAP = {
|
||||
january: 0,
|
||||
jan: 0,
|
||||
february: 1,
|
||||
feb: 1,
|
||||
march: 2,
|
||||
mar: 2,
|
||||
april: 3,
|
||||
apr: 3,
|
||||
may: 4,
|
||||
june: 5,
|
||||
jun: 5,
|
||||
july: 6,
|
||||
jul: 6,
|
||||
august: 7,
|
||||
aug: 7,
|
||||
september: 8,
|
||||
sep: 8,
|
||||
sept: 8,
|
||||
october: 9,
|
||||
oct: 9,
|
||||
november: 10,
|
||||
nov: 10,
|
||||
december: 11,
|
||||
dec: 11,
|
||||
};
|
||||
|
||||
/** Words like "today" or "tomorrow" → how many days from now. */
|
||||
export const RELATIVE_DAY_MAP = {
|
||||
today: 0,
|
||||
tonight: 0,
|
||||
tonite: 0,
|
||||
tomorrow: 1,
|
||||
tmr: 1,
|
||||
tmrw: 1,
|
||||
};
|
||||
|
||||
/** Unit shorthand → full unit name used by date-fns. */
|
||||
export const UNIT_MAP = {
|
||||
m: 'minutes',
|
||||
min: 'minutes',
|
||||
mins: 'minutes',
|
||||
minute: 'minutes',
|
||||
minutes: 'minutes',
|
||||
h: 'hours',
|
||||
hr: 'hours',
|
||||
hrs: 'hours',
|
||||
hour: 'hours',
|
||||
hours: 'hours',
|
||||
d: 'days',
|
||||
day: 'days',
|
||||
days: 'days',
|
||||
w: 'weeks',
|
||||
wk: 'weeks',
|
||||
wks: 'weeks',
|
||||
week: 'weeks',
|
||||
weeks: 'weeks',
|
||||
mo: 'months',
|
||||
month: 'months',
|
||||
months: 'months',
|
||||
y: 'years',
|
||||
yr: 'years',
|
||||
yrs: 'years',
|
||||
year: 'years',
|
||||
years: 'years',
|
||||
};
|
||||
|
||||
/** English number words → their numeric value. */
|
||||
export const WORD_NUMBER_MAP = {
|
||||
a: 1,
|
||||
an: 1,
|
||||
one: 1,
|
||||
two: 2,
|
||||
three: 3,
|
||||
four: 4,
|
||||
five: 5,
|
||||
six: 6,
|
||||
seven: 7,
|
||||
eight: 8,
|
||||
nine: 9,
|
||||
ten: 10,
|
||||
eleven: 11,
|
||||
twelve: 12,
|
||||
thirteen: 13,
|
||||
fourteen: 14,
|
||||
fifteen: 15,
|
||||
sixteen: 16,
|
||||
seventeen: 17,
|
||||
eighteen: 18,
|
||||
nineteen: 19,
|
||||
twenty: 20,
|
||||
thirty: 30,
|
||||
forty: 40,
|
||||
fifty: 50,
|
||||
sixty: 60,
|
||||
ninety: 90,
|
||||
half: 0.5,
|
||||
couple: 2,
|
||||
few: 3,
|
||||
};
|
||||
|
||||
/** Day index → the date-fns function that finds the next occurrence. */
|
||||
export const NEXT_WEEKDAY_FN = {
|
||||
0: nextSunday,
|
||||
1: nextMonday,
|
||||
2: nextTuesday,
|
||||
3: nextWednesday,
|
||||
4: nextThursday,
|
||||
5: nextFriday,
|
||||
6: nextSaturday,
|
||||
};
|
||||
|
||||
/** Time-of-day label → default hour and minute. */
|
||||
export const TIME_OF_DAY_MAP = {
|
||||
morning: { hours: 9, minutes: 0 },
|
||||
noon: { hours: 12, minutes: 0 },
|
||||
afternoon: { hours: 14, minutes: 0 },
|
||||
evening: { hours: 18, minutes: 0 },
|
||||
night: { hours: 20, minutes: 0 },
|
||||
tonight: { hours: 20, minutes: 0 },
|
||||
midnight: { hours: 0, minutes: 0 },
|
||||
eod: { hours: 17, minutes: 0 },
|
||||
'end of day': { hours: 17, minutes: 0 },
|
||||
'end of the day': { hours: 17, minutes: 0 },
|
||||
};
|
||||
|
||||
/** Allowed hour range per label — used to pick am or pm when not specified. */
|
||||
export const TOD_HOUR_RANGE = {
|
||||
morning: [4, 12],
|
||||
noon: [11, 13],
|
||||
afternoon: [12, 18],
|
||||
evening: [16, 22],
|
||||
night: [18, 24],
|
||||
tonight: [18, 24],
|
||||
midnight: [23, 25],
|
||||
};
|
||||
|
||||
/** What "half hour", "half day", etc. actually mean in date-fns terms. */
|
||||
export const HALF_UNIT_DURATIONS = {
|
||||
hour: { minutes: 30 },
|
||||
day: { hours: 12 },
|
||||
week: { days: 3, hours: 12 },
|
||||
month: { days: 15 },
|
||||
year: { months: 6 },
|
||||
};
|
||||
|
||||
const FRACTIONAL_CONVERT = {
|
||||
hours: { unit: 'minutes', factor: 60 },
|
||||
days: { unit: 'hours', factor: 24 },
|
||||
weeks: { unit: 'days', factor: 7 },
|
||||
months: { unit: 'days', factor: 30 },
|
||||
years: { unit: 'months', factor: 12 },
|
||||
};
|
||||
|
||||
// ─── Unicode / Normalization ────────────────────────────────────────────────
|
||||
// Turn non-ASCII digits and punctuation into plain ASCII so the
|
||||
// parser only has to deal with standard characters.
|
||||
|
||||
const UNICODE_DIGIT_RANGES = [
|
||||
[0x30, 0x39],
|
||||
[0x660, 0x669], // Arabic-Indic
|
||||
[0x6f0, 0x6f9], // Eastern Arabic-Indic
|
||||
[0x966, 0x96f], // Devanagari
|
||||
[0x9e6, 0x9ef], // Bengali
|
||||
[0xa66, 0xa6f], // Gurmukhi
|
||||
[0xae6, 0xaef], // Gujarati
|
||||
[0xb66, 0xb6f], // Oriya
|
||||
[0xbe6, 0xbef], // Tamil
|
||||
[0xc66, 0xc6f], // Telugu
|
||||
[0xce6, 0xcef], // Kannada
|
||||
[0xd66, 0xd6f], // Malayalam
|
||||
];
|
||||
|
||||
const toAsciiDigit = char => {
|
||||
const code = char.codePointAt(0);
|
||||
const range = UNICODE_DIGIT_RANGES.find(
|
||||
([start, end]) => code >= start && code <= end
|
||||
);
|
||||
if (!range) return char;
|
||||
return String(code - range[0]);
|
||||
};
|
||||
|
||||
/** Turn non-ASCII digits (Arabic, Devanagari, etc.) into 0-9. */
|
||||
export const normalizeDigits = text => text.replace(/\p{Nd}/gu, toAsciiDigit);
|
||||
|
||||
const ARABIC_PUNCT_MAP = {
|
||||
'\u061f': '?',
|
||||
'\u060c': ',',
|
||||
'\u061b': ';',
|
||||
'\u066b': '.',
|
||||
};
|
||||
|
||||
const NOISE_RE =
|
||||
/^(?:(?:can|could|will|would)\s+you\s+)?(?:(?:please|pls|plz|kindly)\s+)?(?:(?:snooze|remind(?:\s+me)?|set(?:\s+(?:a|the))?(?:\s+(?:reminder|deadline|snooze|timer))?|add(?:\s+(?:a|the))?(?:\s+(?:reminder|deadline|snooze))?|schedule|postpone|defer|delay|push)(?:\s+(?:it|this))?\s+)?(?:(?:on|to|for|at|until|till|by|from)\s+)?/;
|
||||
|
||||
const APPROX_RE = /^(?:approx(?:imately)?|around|about|roughly|~)\s+/;
|
||||
|
||||
/** Clean up raw input: lowercase, remove punctuation, collapse spaces. */
|
||||
export const sanitize = text =>
|
||||
normalizeDigits(
|
||||
text
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
.replace(/[\u200f\u200e\u066c\u0640]/g, '')
|
||||
.replace(/[\u064b-\u065f]/g, '')
|
||||
.replace(/\u00a0/g, ' ')
|
||||
.replace(/[\u061f\u060c\u061b\u066b]/g, c => ARABIC_PUNCT_MAP[c])
|
||||
)
|
||||
.replace(/[,!?;]+/g, ' ')
|
||||
.replace(/\.+$/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
/** Strip filler words like "please snooze for" and fix typos like "tommorow". */
|
||||
export const stripNoise = text =>
|
||||
text
|
||||
.replace(NOISE_RE, '')
|
||||
.replace(APPROX_RE, '')
|
||||
.replace(/\bnxt\b/g, 'next')
|
||||
.replace(/\bcouple\s+of\b/g, 'couple')
|
||||
.replace(/\b(\d+)h(\d+)m?\b/g, '$1 hours $2 minutes')
|
||||
.replace(/\btomm?orow\b/g, 'tomorrow')
|
||||
.trim();
|
||||
|
||||
// ─── Utility Functions ──────────────────────────────────────────────────────
|
||||
|
||||
/** Turn a string into a number. Works with digits ("5") and words ("five"). */
|
||||
export const parseNumber = str => {
|
||||
if (!str) return null;
|
||||
const lower = normalizeDigits(str.toLowerCase().trim());
|
||||
if (WORD_NUMBER_MAP[lower] !== undefined) return WORD_NUMBER_MAP[lower];
|
||||
const num = Number(lower);
|
||||
return Number.isNaN(num) ? null : num;
|
||||
};
|
||||
|
||||
/** Set the time on a date, clearing seconds and milliseconds. */
|
||||
export const applyTimeToDate = (date, hours, minutes = 0) =>
|
||||
set(date, { hours, minutes, seconds: 0, milliseconds: 0 });
|
||||
|
||||
/** Parse "3pm", "14:30", or "2:00am" into { hours, minutes }. Returns null if invalid. */
|
||||
export const parseTimeString = timeStr => {
|
||||
if (!timeStr) return null;
|
||||
const match = timeStr
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '')
|
||||
.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.?|p\.m\.?)?$/);
|
||||
if (!match) return null;
|
||||
|
||||
const raw = parseInt(match[1], 10);
|
||||
const minutes = match[2] ? parseInt(match[2], 10) : 0;
|
||||
const meridiem = match[3]?.replace(/\./g, '');
|
||||
if (meridiem && (raw < 1 || raw > 12)) return null;
|
||||
|
||||
const toHours = (h, m) => {
|
||||
if (m === 'pm' && h < 12) return h + 12;
|
||||
if (m === 'am' && h === 12) return 0;
|
||||
return h;
|
||||
};
|
||||
const hours = toHours(raw, meridiem);
|
||||
if (hours > 23 || minutes > 59) return null;
|
||||
return { hours, minutes };
|
||||
};
|
||||
|
||||
/** Apply a time string to a date. Falls back to 9 AM if no time is given. */
|
||||
export const applyTimeOrDefault = (date, timeStr, defaultHours = 9) => {
|
||||
if (timeStr) {
|
||||
const time = parseTimeString(timeStr);
|
||||
if (!time) return null;
|
||||
return applyTimeToDate(date, time.hours, time.minutes);
|
||||
}
|
||||
return applyTimeToDate(date, defaultHours, 0);
|
||||
};
|
||||
|
||||
/** Build a Date only if the day actually exists (e.g. rejects Feb 30). */
|
||||
export const strictDate = (year, month, day) => {
|
||||
const date = new Date(year, month, day);
|
||||
if (
|
||||
!isValid(date) ||
|
||||
date.getFullYear() !== year ||
|
||||
date.getMonth() !== month ||
|
||||
date.getDate() !== day
|
||||
)
|
||||
return null;
|
||||
return date;
|
||||
};
|
||||
|
||||
/** Try up to 8 years ahead to find a valid future date (handles Feb 29 leap years). */
|
||||
export const futureOrNextYear = (year, month, day, timeStr, now) => {
|
||||
for (let i = 0; i < 9; i += 1) {
|
||||
const base = strictDate(year + i, month, day);
|
||||
if (base) {
|
||||
const date = applyTimeOrDefault(base, timeStr);
|
||||
if (!date) return null;
|
||||
if (isAfter(date, now)) return date;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** If the date is already past, push it to the next day. */
|
||||
export const ensureFutureOrNextDay = (date, now) =>
|
||||
isAfter(date, now) ? date : add(date, { days: 1 });
|
||||
|
||||
/** Figure out am/pm from context: "morning 6" → 6am, "evening 6" → 6pm. */
|
||||
export const inferHoursFromTOD = (todLabel, rawHour, rawMinutes) => {
|
||||
const h = parseInt(rawHour, 10);
|
||||
const m = rawMinutes ? parseInt(rawMinutes, 10) : 0;
|
||||
if (Number.isNaN(h) || h < 1 || h > 12 || m > 59) return null;
|
||||
const range = TOD_HOUR_RANGE[todLabel];
|
||||
if (!range) return { hours: h, minutes: m };
|
||||
// Try both am and pm interpretations, pick the one in range
|
||||
const am = h === 12 ? 0 : h;
|
||||
const pm = h === 12 ? 12 : h + 12;
|
||||
const inRange = v => v >= range[0] && v < range[1];
|
||||
if (inRange(am)) return { hours: am, minutes: m };
|
||||
if (inRange(pm)) return { hours: pm, minutes: m };
|
||||
const mid = (range[0] + range[1]) / 2;
|
||||
return {
|
||||
hours: Math.abs(am - mid) <= Math.abs(pm - mid) ? am : pm,
|
||||
minutes: m,
|
||||
};
|
||||
};
|
||||
|
||||
/** Add a duration that might be fractional, e.g. 1.5 hours becomes 90 minutes. */
|
||||
export const addFractionalSafe = (date, unit, amount) => {
|
||||
if (Number.isInteger(amount)) return add(date, { [unit]: amount });
|
||||
if (amount % 1 !== 0.5) return null;
|
||||
const conv = FRACTIONAL_CONVERT[unit];
|
||||
if (conv) return add(date, { [conv.unit]: Math.round(amount * conv.factor) });
|
||||
return add(date, { [unit]: Math.round(amount) });
|
||||
};
|
||||
@@ -7,16 +7,11 @@ import {
|
||||
startOfMonth,
|
||||
isMonday,
|
||||
isToday,
|
||||
isSameYear,
|
||||
setHours,
|
||||
setMinutes,
|
||||
setSeconds,
|
||||
} from 'date-fns';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import {
|
||||
generateDateSuggestions,
|
||||
parseDateFromText,
|
||||
} from 'dashboard/helper/snoozeDateParser';
|
||||
|
||||
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
|
||||
|
||||
@@ -38,95 +33,65 @@ export const findStartOfNextMonth = currentDate => {
|
||||
});
|
||||
};
|
||||
|
||||
export const findNextDay = currentDate => add(currentDate, { days: 1 });
|
||||
export const findNextDay = currentDate => {
|
||||
return add(currentDate, { days: 1 });
|
||||
};
|
||||
|
||||
export const setHoursToNine = date =>
|
||||
setSeconds(setMinutes(setHours(date, 9), 0), 0);
|
||||
|
||||
const SNOOZE_RESOLVERS = {
|
||||
[SNOOZE_OPTIONS.AN_HOUR_FROM_NOW]: d => add(d, { hours: 1 }),
|
||||
[SNOOZE_OPTIONS.UNTIL_TOMORROW]: d => setHoursToNine(findNextDay(d)),
|
||||
[SNOOZE_OPTIONS.UNTIL_NEXT_WEEK]: d => setHoursToNine(findStartOfNextWeek(d)),
|
||||
[SNOOZE_OPTIONS.UNTIL_NEXT_MONTH]: d =>
|
||||
setHoursToNine(findStartOfNextMonth(d)),
|
||||
export const setHoursToNine = date => {
|
||||
return setSeconds(setMinutes(setHours(date, 9), 0), 0);
|
||||
};
|
||||
|
||||
export const findSnoozeTime = (snoozeType, currentDate = new Date()) => {
|
||||
const resolve = SNOOZE_RESOLVERS[snoozeType];
|
||||
return resolve ? getUnixTime(resolve(currentDate)) : null;
|
||||
};
|
||||
|
||||
export const snoozedReopenTime = snoozedUntil => {
|
||||
if (!snoozedUntil) return null;
|
||||
const date = new Date(snoozedUntil);
|
||||
if (isToday(date)) return format(date, 'h.mmaaa');
|
||||
if (!isSameYear(date, new Date())) return format(date, 'd MMM yyyy, h.mmaaa');
|
||||
return format(date, 'd MMM, h.mmaaa');
|
||||
};
|
||||
|
||||
export const snoozedReopenTimeToTimestamp = snoozedUntil =>
|
||||
snoozedUntil ? getUnixTime(new Date(snoozedUntil)) : null;
|
||||
|
||||
const formatSnoozeDate = (snoozeDate, currentDate, locale = 'en') => {
|
||||
const sameYear = isSameYear(snoozeDate, currentDate);
|
||||
try {
|
||||
const opts = {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
...(sameYear ? {} : { year: 'numeric' }),
|
||||
};
|
||||
return new Intl.DateTimeFormat(locale, opts).format(snoozeDate);
|
||||
} catch {
|
||||
return sameYear
|
||||
? format(snoozeDate, 'EEE, d MMM, h:mm a')
|
||||
: format(snoozeDate, 'EEE, d MMM yyyy, h:mm a');
|
||||
let parsedDate = null;
|
||||
if (snoozeType === SNOOZE_OPTIONS.AN_HOUR_FROM_NOW) {
|
||||
parsedDate = add(currentDate, { hours: 1 });
|
||||
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_TOMORROW) {
|
||||
parsedDate = setHoursToNine(findNextDay(currentDate));
|
||||
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_WEEK) {
|
||||
parsedDate = setHoursToNine(findStartOfNextWeek(currentDate));
|
||||
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_MONTH) {
|
||||
parsedDate = setHoursToNine(findStartOfNextMonth(currentDate));
|
||||
}
|
||||
|
||||
return parsedDate ? getUnixTime(parsedDate) : null;
|
||||
};
|
||||
export const snoozedReopenTime = snoozedUntil => {
|
||||
if (!snoozedUntil) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(snoozedUntil);
|
||||
|
||||
if (isToday(date)) {
|
||||
return format(date, 'h.mmaaa');
|
||||
}
|
||||
return snoozedUntil ? format(date, 'd MMM, h.mmaaa') : null;
|
||||
};
|
||||
|
||||
const capitalizeLabel = text => text.replace(/^\w/, c => c.toUpperCase());
|
||||
|
||||
export const generateSnoozeSuggestions = (
|
||||
searchText,
|
||||
currentDate = new Date(),
|
||||
{ translations, locale } = {}
|
||||
) => {
|
||||
const suggestions = generateDateSuggestions(searchText, currentDate, {
|
||||
translations,
|
||||
locale,
|
||||
});
|
||||
return suggestions.map(s => ({
|
||||
date: s.date,
|
||||
unixTime: s.unix,
|
||||
query: s.query,
|
||||
label: capitalizeLabel(s.label),
|
||||
formattedDate: formatSnoozeDate(s.date, currentDate, locale),
|
||||
resolve: () => parseDateFromText(s.query)?.unix ?? s.unix,
|
||||
}));
|
||||
export const snoozedReopenTimeToTimestamp = snoozedUntil => {
|
||||
return snoozedUntil ? getUnixTime(new Date(snoozedUntil)) : null;
|
||||
};
|
||||
|
||||
const UNIT_SHORT = {
|
||||
minute: 'm',
|
||||
minutes: 'm',
|
||||
hour: 'h',
|
||||
hours: 'h',
|
||||
day: 'd',
|
||||
days: 'd',
|
||||
month: 'mo',
|
||||
months: 'mo',
|
||||
year: 'y',
|
||||
years: 'y',
|
||||
};
|
||||
|
||||
export const shortenSnoozeTime = snoozedUntil => {
|
||||
if (!snoozedUntil) return null;
|
||||
return snoozedUntil
|
||||
if (!snoozedUntil) {
|
||||
return null;
|
||||
}
|
||||
const unitMap = {
|
||||
minutes: 'm',
|
||||
minute: 'm',
|
||||
hours: 'h',
|
||||
hour: 'h',
|
||||
days: 'd',
|
||||
day: 'd',
|
||||
months: 'mo',
|
||||
month: 'mo',
|
||||
years: 'y',
|
||||
year: 'y',
|
||||
};
|
||||
const shortenTime = snoozedUntil
|
||||
.replace(/^in\s+/i, '')
|
||||
.replace(
|
||||
/\s(minute|hour|day|month|year)s?\b/gi,
|
||||
(match, unit) => UNIT_SHORT[unit.toLowerCase()] || match
|
||||
(match, unit) => unitMap[unit.toLowerCase()] || match
|
||||
);
|
||||
|
||||
return shortenTime;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -91,26 +91,12 @@ describe('#Snooze Helpers', () => {
|
||||
});
|
||||
|
||||
describe('snoozedReopenTime', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
|
||||
it('should return nil if snoozedUntil is nil', () => {
|
||||
expect(snoozedReopenTime(null)).toEqual(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return formatted date with year if snoozedUntil is not in current year', () => {
|
||||
// Input is 09:00 UTC.
|
||||
// If your environment is UTC, this will be 9.00am.
|
||||
it('should return formatted date if snoozedUntil is not nil', () => {
|
||||
expect(snoozedReopenTime('2023-06-07T09:00:00.000Z')).toEqual(
|
||||
'7 Jun 2023, 9.00am'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return formatted date without year if snoozedUntil is in current year', () => {
|
||||
// This uses 2024 because we mocked the system time above
|
||||
expect(snoozedReopenTime('2024-06-07T09:00:00.000Z')).toEqual(
|
||||
'7 Jun, 9.00am'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -33,7 +33,6 @@ import setNewPassword from './setNewPassword.json';
|
||||
import settings from './settings.json';
|
||||
import signup from './signup.json';
|
||||
import sla from './sla.json';
|
||||
import snooze from './snooze.json';
|
||||
import teamsSettings from './teamsSettings.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
|
||||
@@ -73,7 +72,6 @@ export default {
|
||||
...settings,
|
||||
...signup,
|
||||
...sla,
|
||||
...snooze,
|
||||
...teamsSettings,
|
||||
...whatsappTemplates,
|
||||
};
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"SNOOZE_PARSER": {
|
||||
"UNITS": {
|
||||
"MINUTE": "دقيقة",
|
||||
"MINUTES": "دقائق",
|
||||
"HOUR": "ساعة",
|
||||
"HOURS": "ساعات",
|
||||
"DAY": "يوم",
|
||||
"DAYS": "أيام",
|
||||
"WEEK": "أسبوع",
|
||||
"WEEKS": "أسابيع",
|
||||
"MONTH": "شهر",
|
||||
"MONTHS": "أشهر",
|
||||
"YEAR": "سنة",
|
||||
"YEARS": "سنوات"
|
||||
},
|
||||
"HALF": "نصف",
|
||||
"NEXT": "القادم",
|
||||
"THIS": "هذا",
|
||||
"AT": "الساعة",
|
||||
"IN": "في",
|
||||
"FROM_NOW": "من الآن",
|
||||
"NEXT_YEAR": "العام المقبل",
|
||||
"MERIDIEM": {
|
||||
"AM": "صباحاً",
|
||||
"PM": "مساءً"
|
||||
},
|
||||
"RELATIVE": {
|
||||
"TOMORROW": "غداً",
|
||||
"DAY_AFTER_TOMORROW": "بعد غد",
|
||||
"NEXT_WEEK": "الأسبوع القادم",
|
||||
"NEXT_MONTH": "الشهر القادم",
|
||||
"THIS_WEEKEND": "نهاية هذا الأسبوع",
|
||||
"NEXT_WEEKEND": "نهاية الأسبوع القادم"
|
||||
},
|
||||
"TIME_OF_DAY": {
|
||||
"MORNING": "صباحاً",
|
||||
"AFTERNOON": "بعد الظهر",
|
||||
"EVENING": "مساءً",
|
||||
"NIGHT": "ليلاً",
|
||||
"NOON": "ظهراً",
|
||||
"MIDNIGHT": "منتصف الليل"
|
||||
},
|
||||
"WORD_NUMBERS": {
|
||||
"ONE": "واحد",
|
||||
"TWO": "اثنان",
|
||||
"THREE": "ثلاثة",
|
||||
"FOUR": "أربعة",
|
||||
"FIVE": "خمسة",
|
||||
"SIX": "ستة",
|
||||
"SEVEN": "سبعة",
|
||||
"EIGHT": "ثمانية",
|
||||
"NINE": "تسعة",
|
||||
"TEN": "عشرة",
|
||||
"TWELVE": "اثنا عشر",
|
||||
"FIFTEEN": "خمسة عشر",
|
||||
"TWENTY": "عشرون",
|
||||
"THIRTY": "ثلاثون"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -613,7 +613,7 @@
|
||||
"NO_INBOX_ALERT": "There are no available inboxes to start a conversation with this contact.",
|
||||
"CONTACT_SELECTOR": {
|
||||
"LABEL": "To:",
|
||||
"TAG_INPUT_PLACEHOLDER": "Search for a contact with name, email or phone number",
|
||||
"TAG_INPUT_PLACEHOLDER": "Enter at least 2 characters to search by name, email, or phone number",
|
||||
"CONTACT_CREATING": "Creating contact..."
|
||||
},
|
||||
"INBOX_SELECTOR": {
|
||||
@@ -624,9 +624,9 @@
|
||||
"SUBJECT_LABEL": "Subject :",
|
||||
"SUBJECT_PLACEHOLDER": "Enter your email subject here",
|
||||
"CC_LABEL": "Cc:",
|
||||
"CC_PLACEHOLDER": "Search for a contact with their email address",
|
||||
"CC_PLACEHOLDER": "Enter at least 2 characters to search by email",
|
||||
"BCC_LABEL": "Bcc:",
|
||||
"BCC_PLACEHOLDER": "Search for a contact with their email address",
|
||||
"BCC_PLACEHOLDER": "Enter at least 2 characters to search by email",
|
||||
"BCC_BUTTON": "Bcc"
|
||||
},
|
||||
"MESSAGE_EDITOR": {
|
||||
|
||||
@@ -5,11 +5,6 @@
|
||||
"WEEK_NUMBER": "Week #{weekNumber}",
|
||||
"APPLY_BUTTON": "Apply",
|
||||
"CLEAR_BUTTON": "Clear",
|
||||
"HOUR": "Hour",
|
||||
"MINUTE": "Min",
|
||||
"SECOND": "Sec",
|
||||
"FORMAT_12H": "12h",
|
||||
"FORMAT_24H": "24h",
|
||||
"DATE_RANGE_INPUT": {
|
||||
"START": "Start Date",
|
||||
"END": "End Date"
|
||||
|
||||
@@ -182,7 +182,6 @@
|
||||
},
|
||||
"COMMAND_BAR": {
|
||||
"SEARCH_PLACEHOLDER": "Search or jump to",
|
||||
"SNOOZE_PLACEHOLDER": "Type a time e.g. tomorrow, 2 hours, next friday, jan 15...",
|
||||
"SECTIONS": {
|
||||
"GENERAL": "General",
|
||||
"REPORTS": "Reports",
|
||||
|
||||
@@ -374,6 +374,16 @@
|
||||
"ERROR_MESSAGE": "Error while deleting article"
|
||||
}
|
||||
},
|
||||
"REORDER_ARTICLE": {
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "Unable to reorder articles. Please try again."
|
||||
}
|
||||
},
|
||||
"REORDER_CATEGORY": {
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "Unable to reorder categories. Please try again."
|
||||
}
|
||||
},
|
||||
"CREATE_ARTICLE": {
|
||||
"ERROR_MESSAGE": "Please add the article heading and content then only you can update the settings"
|
||||
},
|
||||
@@ -839,7 +849,7 @@
|
||||
"STATUS": {
|
||||
"UPLOADED": "Ready",
|
||||
"PROCESSING": "Processing",
|
||||
"PROCESSED": "Completed",
|
||||
"PROCESSED": "Completed",
|
||||
"FAILED": "Failed"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -34,7 +34,6 @@ import setNewPassword from './setNewPassword.json';
|
||||
import settings from './settings.json';
|
||||
import signup from './signup.json';
|
||||
import sla from './sla.json';
|
||||
import snooze from './snooze.json';
|
||||
import teamsSettings from './teamsSettings.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
import contentTemplates from './contentTemplates.json';
|
||||
@@ -78,7 +77,6 @@ export default {
|
||||
...settings,
|
||||
...signup,
|
||||
...sla,
|
||||
...snooze,
|
||||
...teamsSettings,
|
||||
...whatsappTemplates,
|
||||
...contentTemplates,
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"SNOOZE_PARSER": {
|
||||
"UNITS": {
|
||||
"MINUTE": "minute",
|
||||
"MINUTES": "minutes",
|
||||
"HOUR": "hour",
|
||||
"HOURS": "hours",
|
||||
"DAY": "day",
|
||||
"DAYS": "days",
|
||||
"WEEK": "week",
|
||||
"WEEKS": "weeks",
|
||||
"MONTH": "month",
|
||||
"MONTHS": "months",
|
||||
"YEAR": "year",
|
||||
"YEARS": "years"
|
||||
},
|
||||
"HALF": "half",
|
||||
"NEXT": "next",
|
||||
"THIS": "this",
|
||||
"AT": "at",
|
||||
"IN": "in",
|
||||
"FROM_NOW": "from now",
|
||||
"NEXT_YEAR": "next year",
|
||||
"MERIDIEM": {
|
||||
"AM": "am",
|
||||
"PM": "pm"
|
||||
},
|
||||
"RELATIVE": {
|
||||
"TOMORROW": "tomorrow",
|
||||
"DAY_AFTER_TOMORROW": "day after tomorrow",
|
||||
"NEXT_WEEK": "next week",
|
||||
"NEXT_MONTH": "next month",
|
||||
"THIS_WEEKEND": "this weekend",
|
||||
"NEXT_WEEKEND": "next weekend"
|
||||
},
|
||||
"TIME_OF_DAY": {
|
||||
"MORNING": "morning",
|
||||
"AFTERNOON": "afternoon",
|
||||
"EVENING": "evening",
|
||||
"NIGHT": "night",
|
||||
"NOON": "noon",
|
||||
"MIDNIGHT": "midnight"
|
||||
},
|
||||
"WORD_NUMBERS": {
|
||||
"ONE": "one",
|
||||
"TWO": "two",
|
||||
"THREE": "three",
|
||||
"FOUR": "four",
|
||||
"FIVE": "five",
|
||||
"SIX": "six",
|
||||
"SEVEN": "seven",
|
||||
"EIGHT": "eight",
|
||||
"NINE": "nine",
|
||||
"TEN": "ten",
|
||||
"TWELVE": "twelve",
|
||||
"FIFTEEN": "fifteen",
|
||||
"TWENTY": "twenty",
|
||||
"THIRTY": "thirty"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,6 @@ import setNewPassword from './setNewPassword.json';
|
||||
import settings from './settings.json';
|
||||
import signup from './signup.json';
|
||||
import sla from './sla.json';
|
||||
import snooze from './snooze.json';
|
||||
import teamsSettings from './teamsSettings.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
|
||||
@@ -73,7 +72,6 @@ export default {
|
||||
...settings,
|
||||
...signup,
|
||||
...sla,
|
||||
...snooze,
|
||||
...teamsSettings,
|
||||
...whatsappTemplates,
|
||||
};
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"SNOOZE_PARSER": {
|
||||
"UNITS": {
|
||||
"MINUTE": "minuto",
|
||||
"MINUTES": "minutos",
|
||||
"HOUR": "hora",
|
||||
"HOURS": "horas",
|
||||
"DAY": "día",
|
||||
"DAYS": "días",
|
||||
"WEEK": "semana",
|
||||
"WEEKS": "semanas",
|
||||
"MONTH": "mes",
|
||||
"MONTHS": "meses",
|
||||
"YEAR": "año",
|
||||
"YEARS": "años"
|
||||
},
|
||||
"HALF": "media",
|
||||
"NEXT": "próximo",
|
||||
"THIS": "este",
|
||||
"AT": "a las",
|
||||
"IN": "en",
|
||||
"FROM_NOW": "a partir de ahora",
|
||||
"NEXT_YEAR": "el próximo año",
|
||||
"MERIDIEM": {
|
||||
"AM": "am",
|
||||
"PM": "pm"
|
||||
},
|
||||
"RELATIVE": {
|
||||
"TOMORROW": "mañana",
|
||||
"DAY_AFTER_TOMORROW": "pasado mañana",
|
||||
"NEXT_WEEK": "la próxima semana",
|
||||
"NEXT_MONTH": "el próximo mes",
|
||||
"THIS_WEEKEND": "este fin de semana",
|
||||
"NEXT_WEEKEND": "el próximo fin de semana"
|
||||
},
|
||||
"TIME_OF_DAY": {
|
||||
"MORNING": "mañana",
|
||||
"AFTERNOON": "tarde",
|
||||
"EVENING": "noche",
|
||||
"NIGHT": "noche",
|
||||
"NOON": "mediodía",
|
||||
"MIDNIGHT": "medianoche"
|
||||
},
|
||||
"WORD_NUMBERS": {
|
||||
"ONE": "uno",
|
||||
"TWO": "dos",
|
||||
"THREE": "tres",
|
||||
"FOUR": "cuatro",
|
||||
"FIVE": "cinco",
|
||||
"SIX": "seis",
|
||||
"SEVEN": "siete",
|
||||
"EIGHT": "ocho",
|
||||
"NINE": "nueve",
|
||||
"TEN": "diez",
|
||||
"TWELVE": "doce",
|
||||
"FIFTEEN": "quince",
|
||||
"TWENTY": "veinte",
|
||||
"THIRTY": "treinta"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,6 @@ import setNewPassword from './setNewPassword.json';
|
||||
import settings from './settings.json';
|
||||
import signup from './signup.json';
|
||||
import sla from './sla.json';
|
||||
import snooze from './snooze.json';
|
||||
import teamsSettings from './teamsSettings.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
|
||||
@@ -73,7 +72,6 @@ export default {
|
||||
...settings,
|
||||
...signup,
|
||||
...sla,
|
||||
...snooze,
|
||||
...teamsSettings,
|
||||
...whatsappTemplates,
|
||||
};
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
{
|
||||
"SNOOZE_PARSER": {
|
||||
"UNITS": {
|
||||
"MINUTE": "മിനിറ്റ്",
|
||||
"MINUTES": "മിനിറ്റ്",
|
||||
"HOUR": "മണിക്കൂർ",
|
||||
"HOURS": "മണിക്കൂർ",
|
||||
"DAY": "ദിവസം",
|
||||
"DAYS": "ദിവസം",
|
||||
"WEEK": "ആഴ്ച",
|
||||
"WEEKS": "ആഴ്ച",
|
||||
"MONTH": "മാസം",
|
||||
"MONTHS": "മാസം",
|
||||
"YEAR": "വർഷം",
|
||||
"YEARS": "വർഷം"
|
||||
},
|
||||
"HALF": "അര",
|
||||
"NEXT": "അടുത്ത",
|
||||
"THIS": "ഈ",
|
||||
"AT": "സമയം",
|
||||
"IN": "കഴിഞ്ഞ്",
|
||||
"FROM_NOW": "ഇപ്പോൾ മുതൽ",
|
||||
"NEXT_YEAR": "അടുത്ത വർഷം",
|
||||
"MERIDIEM": { "AM": "രാവിലെ", "PM": "വൈകുന്നേരം" },
|
||||
"RELATIVE": {
|
||||
"TOMORROW": "നാളെ",
|
||||
"DAY_AFTER_TOMORROW": "മറ്റന്നാൾ",
|
||||
"NEXT_WEEK": "അടുത്ത ആഴ്ച",
|
||||
"NEXT_MONTH": "അടുത്ത മാസം",
|
||||
"THIS_WEEKEND": "ഈ വാരാന്ത്യം",
|
||||
"NEXT_WEEKEND": "അടുത്ത വാരാന്ത്യം"
|
||||
},
|
||||
"TIME_OF_DAY": {
|
||||
"MORNING": "രാവിലെ",
|
||||
"AFTERNOON": "ഉച്ചയ്ക്ക്",
|
||||
"EVENING": "വൈകുന്നേരം",
|
||||
"NIGHT": "രാത്രി",
|
||||
"NOON": "ഉച്ച",
|
||||
"MIDNIGHT": "അർദ്ധരാത്രി"
|
||||
},
|
||||
"WORD_NUMBERS": {
|
||||
"ONE": "ഒന്ന്",
|
||||
"TWO": "രണ്ട്",
|
||||
"THREE": "മൂന്ന്",
|
||||
"FOUR": "നാല്",
|
||||
"FIVE": "അഞ്ച്",
|
||||
"SIX": "ആറ്",
|
||||
"SEVEN": "ഏഴ്",
|
||||
"EIGHT": "എട്ട്",
|
||||
"NINE": "ഒൻപത്",
|
||||
"TEN": "പത്ത്",
|
||||
"TWELVE": "പന്ത്രണ്ട്",
|
||||
"FIFTEEN": "പതിനഞ്ച്",
|
||||
"TWENTY": "ഇരുപത്",
|
||||
"THIRTY": "മുപ്പത്"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,6 @@ import setNewPassword from './setNewPassword.json';
|
||||
import settings from './settings.json';
|
||||
import signup from './signup.json';
|
||||
import sla from './sla.json';
|
||||
import snooze from './snooze.json';
|
||||
import teamsSettings from './teamsSettings.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
|
||||
@@ -73,7 +72,6 @@ export default {
|
||||
...settings,
|
||||
...signup,
|
||||
...sla,
|
||||
...snooze,
|
||||
...teamsSettings,
|
||||
...whatsappTemplates,
|
||||
};
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"SNOOZE_PARSER": {
|
||||
"UNITS": {
|
||||
"MINUTE": "minuto",
|
||||
"MINUTES": "minutos",
|
||||
"HOUR": "hora",
|
||||
"HOURS": "horas",
|
||||
"DAY": "dia",
|
||||
"DAYS": "dias",
|
||||
"WEEK": "semana",
|
||||
"WEEKS": "semanas",
|
||||
"MONTH": "mês",
|
||||
"MONTHS": "meses",
|
||||
"YEAR": "ano",
|
||||
"YEARS": "anos"
|
||||
},
|
||||
"HALF": "meia",
|
||||
"NEXT": "próximo",
|
||||
"THIS": "este",
|
||||
"AT": "às",
|
||||
"IN": "em",
|
||||
"FROM_NOW": "a partir de agora",
|
||||
"NEXT_YEAR": "próximo ano",
|
||||
"MERIDIEM": {
|
||||
"AM": "am",
|
||||
"PM": "pm"
|
||||
},
|
||||
"RELATIVE": {
|
||||
"TOMORROW": "amanhã",
|
||||
"DAY_AFTER_TOMORROW": "depois de amanhã",
|
||||
"NEXT_WEEK": "próxima semana",
|
||||
"NEXT_MONTH": "próximo mês",
|
||||
"THIS_WEEKEND": "este fim de semana",
|
||||
"NEXT_WEEKEND": "próximo fim de semana"
|
||||
},
|
||||
"TIME_OF_DAY": {
|
||||
"MORNING": "manhã",
|
||||
"AFTERNOON": "tarde",
|
||||
"EVENING": "noite",
|
||||
"NIGHT": "noite",
|
||||
"NOON": "meio-dia",
|
||||
"MIDNIGHT": "meia-noite"
|
||||
},
|
||||
"WORD_NUMBERS": {
|
||||
"ONE": "um",
|
||||
"TWO": "dois",
|
||||
"THREE": "três",
|
||||
"FOUR": "quatro",
|
||||
"FIVE": "cinco",
|
||||
"SIX": "seis",
|
||||
"SEVEN": "sete",
|
||||
"EIGHT": "oito",
|
||||
"NINE": "nove",
|
||||
"TEN": "dez",
|
||||
"TWELVE": "doze",
|
||||
"FIFTEEN": "quinze",
|
||||
"TWENTY": "vinte",
|
||||
"THIRTY": "trinta"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { searchContacts } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||
import { createContactSearcher } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { fetchContactDetails } from '../helpers/searchHelper';
|
||||
|
||||
@@ -18,6 +18,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
|
||||
const searchContacts = createContactSearcher();
|
||||
|
||||
const FROM_TYPE = {
|
||||
CONTACT: 'contact',
|
||||
AGENT: 'agent',
|
||||
@@ -119,7 +121,10 @@ const debouncedSearch = debounce(async query => {
|
||||
}
|
||||
|
||||
try {
|
||||
const contacts = await searchContacts(query);
|
||||
const contacts = await searchContacts(query, { skipMinLength: true });
|
||||
|
||||
// null means the request was aborted (a newer search is in-flight),
|
||||
if (contacts === null) return;
|
||||
|
||||
// Add selected contact to top if not already in results
|
||||
const allContacts = selectedContact.value
|
||||
@@ -130,9 +135,8 @@ const debouncedSearch = debounce(async query => {
|
||||
: contacts;
|
||||
|
||||
searchedContacts.value = allContacts;
|
||||
isSearching.value = false;
|
||||
} catch {
|
||||
// Ignore error
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
}, 300);
|
||||
|
||||
@@ -13,7 +13,7 @@ import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
const snoozeModalRef = ref(null);
|
||||
const showCustomSnoozeModal = ref(false);
|
||||
|
||||
const selectedChat = computed(() => getters.getSelectedChat.value);
|
||||
const contextMenuChatId = computed(() => getters.getContextMenuChatId.value);
|
||||
@@ -30,9 +30,7 @@ const toggleStatus = async (status, snoozedUntil) => {
|
||||
|
||||
const onCmdSnoozeConversation = snoozeType => {
|
||||
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
snoozeModalRef.value?.open();
|
||||
} else if (typeof snoozeType === 'number') {
|
||||
toggleStatus(wootConstants.STATUS_TYPE.SNOOZED, snoozeType);
|
||||
showCustomSnoozeModal.value = true;
|
||||
} else {
|
||||
toggleStatus(
|
||||
wootConstants.STATUS_TYPE.SNOOZED,
|
||||
@@ -42,6 +40,7 @@ const onCmdSnoozeConversation = snoozeType => {
|
||||
};
|
||||
|
||||
const chooseSnoozeTime = customSnoozeTime => {
|
||||
showCustomSnoozeModal.value = false;
|
||||
if (customSnoozeTime) {
|
||||
toggleStatus(
|
||||
wootConstants.STATUS_TYPE.SNOOZED,
|
||||
@@ -50,17 +49,24 @@ const chooseSnoozeTime = customSnoozeTime => {
|
||||
}
|
||||
};
|
||||
|
||||
const clearContextMenu = () => {
|
||||
const hideCustomSnoozeModal = () => {
|
||||
// if we select custom snooze and the custom snooze modal is open
|
||||
// Then if the custom snooze modal is closed then set the context menu chat id to null
|
||||
store.dispatch('setContextMenuChatId', null);
|
||||
showCustomSnoozeModal.value = false;
|
||||
};
|
||||
|
||||
useEmitter(CMD_SNOOZE_CONVERSATION, onCmdSnoozeConversation);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CustomSnoozeModal
|
||||
ref="snoozeModalRef"
|
||||
@choose-time="chooseSnoozeTime"
|
||||
@close="clearContextMenu"
|
||||
/>
|
||||
<woot-modal
|
||||
v-model:show="showCustomSnoozeModal"
|
||||
:on-close="hideCustomSnoozeModal"
|
||||
>
|
||||
<CustomSnoozeModal
|
||||
@close="hideCustomSnoozeModal"
|
||||
@choose-time="chooseSnoozeTime"
|
||||
/>
|
||||
</woot-modal>
|
||||
</template>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ref, computed, watchEffect, onMounted } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useLocale } from 'shared/composables/useLocale';
|
||||
import { useAppearanceHotKeys } from 'dashboard/composables/commands/useAppearanceHotKeys';
|
||||
import { useInboxHotKeys } from 'dashboard/composables/commands/useInboxHotKeys';
|
||||
import { useGoToCommandHotKeys } from 'dashboard/composables/commands/useGoToCommandHotKeys';
|
||||
@@ -12,18 +11,9 @@ import { useBulkActionsHotKeys } from 'dashboard/composables/commands/useBulkAct
|
||||
import { useConversationHotKeys } from 'dashboard/composables/commands/useConversationHotKeys';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { GENERAL_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { generateSnoozeSuggestions } from 'dashboard/helper/snoozeHelpers';
|
||||
import { ICON_SNOOZE_CONVERSATION } from 'dashboard/helper/commandbar/icons';
|
||||
import {
|
||||
CMD_SNOOZE_CONVERSATION,
|
||||
CMD_SNOOZE_NOTIFICATION,
|
||||
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||
} from 'dashboard/helper/commandbar/events';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
const store = useStore();
|
||||
const { t, tm } = useI18n();
|
||||
const { resolvedLocale } = useLocale();
|
||||
const { t } = useI18n();
|
||||
|
||||
const ninjakeys = ref(null);
|
||||
|
||||
@@ -38,26 +28,9 @@ const { goToCommandHotKeys } = useGoToCommandHotKeys();
|
||||
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
|
||||
const { conversationHotKeys } = useConversationHotKeys();
|
||||
|
||||
const SNOOZE_PARENT_IDS = [
|
||||
'snooze_conversation',
|
||||
'snooze_notification',
|
||||
'bulk_action_snooze_conversation',
|
||||
];
|
||||
const DYNAMIC_SNOOZE_PREFIX = 'dynamic_snooze_';
|
||||
|
||||
const CUSTOM_SNOOZE = wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME;
|
||||
|
||||
const dynamicSnoozeActions = ref([]);
|
||||
const currentCommandRoot = ref(null);
|
||||
|
||||
const placeholder = computed(() =>
|
||||
SNOOZE_PARENT_IDS.includes(currentCommandRoot.value)
|
||||
? t('COMMAND_BAR.SNOOZE_PLACEHOLDER')
|
||||
: t('COMMAND_BAR.SEARCH_PLACEHOLDER')
|
||||
);
|
||||
const placeholder = computed(() => t('COMMAND_BAR.SEARCH_PLACEHOLDER'));
|
||||
|
||||
const hotKeys = computed(() => [
|
||||
...dynamicSnoozeActions.value,
|
||||
...inboxHotKeys.value,
|
||||
...goToCommandHotKeys.value,
|
||||
...goToAppearanceHotKeys.value,
|
||||
@@ -69,125 +42,34 @@ const setCommandBarData = () => {
|
||||
ninjakeys.value.data = hotKeys.value;
|
||||
};
|
||||
|
||||
const SNOOZE_EVENT_MAP = {
|
||||
snooze_conversation: CMD_SNOOZE_CONVERSATION,
|
||||
snooze_notification: CMD_SNOOZE_NOTIFICATION,
|
||||
bulk_action_snooze_conversation: CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||
};
|
||||
|
||||
const SNOOZE_SECTION_MAP = {
|
||||
snooze_conversation: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
|
||||
snooze_notification: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
|
||||
bulk_action_snooze_conversation: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
|
||||
};
|
||||
|
||||
const snoozeTranslations = computed(() => {
|
||||
const raw = tm('SNOOZE_PARSER');
|
||||
if (!raw || typeof raw !== 'object') return {};
|
||||
return JSON.parse(JSON.stringify(raw));
|
||||
});
|
||||
|
||||
const buildDynamicSnoozeActions = (search, parentId) => {
|
||||
const suggestions = generateSnoozeSuggestions(search, new Date(), {
|
||||
translations: snoozeTranslations.value,
|
||||
locale: resolvedLocale.value,
|
||||
});
|
||||
if (!suggestions.length) return [];
|
||||
|
||||
const busEvent = SNOOZE_EVENT_MAP[parentId];
|
||||
const section = t(SNOOZE_SECTION_MAP[parentId]);
|
||||
|
||||
return suggestions.map((parsed, index) => ({
|
||||
id: `${DYNAMIC_SNOOZE_PREFIX}${index}`,
|
||||
title:
|
||||
parsed.label !== parsed.formattedDate
|
||||
? `${parsed.label} — ${parsed.formattedDate}`
|
||||
: parsed.formattedDate,
|
||||
parent: parentId,
|
||||
section,
|
||||
icon: ICON_SNOOZE_CONVERSATION,
|
||||
keywords: search,
|
||||
handler: () => emitter.emit(busEvent, parsed.resolve()),
|
||||
}));
|
||||
};
|
||||
|
||||
const resetSnoozeState = () => {
|
||||
currentCommandRoot.value = null;
|
||||
dynamicSnoozeActions.value = [];
|
||||
};
|
||||
|
||||
const patchNinjaKeysOpenClose = el => {
|
||||
if (!el || typeof el.open !== 'function' || typeof el.close !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalOpen = el.open.bind(el);
|
||||
const originalClose = el.close.bind(el);
|
||||
|
||||
el.open = (...args) => {
|
||||
const [options = {}] = args;
|
||||
currentCommandRoot.value = options.parent || null;
|
||||
dynamicSnoozeActions.value = [];
|
||||
return originalOpen(...args);
|
||||
};
|
||||
|
||||
el.close = (...args) => {
|
||||
resetSnoozeState();
|
||||
return originalClose(...args);
|
||||
};
|
||||
};
|
||||
|
||||
const onSelected = item => {
|
||||
const {
|
||||
detail: {
|
||||
action: { title = null, section = null, id = null, children = null } = {},
|
||||
} = {},
|
||||
detail: { action: { title = null, section = null, id = null } = {} } = {},
|
||||
} = item;
|
||||
|
||||
selectedSnoozeType.value = id === CUSTOM_SNOOZE ? id : null;
|
||||
|
||||
if (Array.isArray(children) && children.length) {
|
||||
currentCommandRoot.value = id;
|
||||
// Added this condition to prevent setting the selectedSnoozeType to null
|
||||
// When we select the "custom snooze" (CMD bar will close and the custom snooze modal will open)
|
||||
if (id === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
selectedSnoozeType.value = wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME;
|
||||
} else {
|
||||
selectedSnoozeType.value = null;
|
||||
}
|
||||
|
||||
useTrack(GENERAL_EVENTS.COMMAND_BAR, { section, action: title });
|
||||
useTrack(GENERAL_EVENTS.COMMAND_BAR, {
|
||||
section,
|
||||
action: title,
|
||||
});
|
||||
|
||||
setCommandBarData();
|
||||
};
|
||||
|
||||
const onCommandBarChange = item => {
|
||||
const { detail: { search = '', actions = [] } = {} } = item;
|
||||
const normalizedSearch = search.trim();
|
||||
|
||||
if (actions.length > 0) {
|
||||
const uniqueParents = [
|
||||
...new Set(actions.map(action => action.parent).filter(Boolean)),
|
||||
];
|
||||
if (uniqueParents.length === 1) {
|
||||
currentCommandRoot.value = uniqueParents[0];
|
||||
} else {
|
||||
currentCommandRoot.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!normalizedSearch ||
|
||||
!SNOOZE_PARENT_IDS.includes(currentCommandRoot.value || '')
|
||||
) {
|
||||
dynamicSnoozeActions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
dynamicSnoozeActions.value = buildDynamicSnoozeActions(
|
||||
normalizedSearch,
|
||||
currentCommandRoot.value
|
||||
);
|
||||
};
|
||||
|
||||
const onClosed = () => {
|
||||
if (selectedSnoozeType.value !== CUSTOM_SNOOZE) {
|
||||
// If the selectedSnoozeType is not "SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME (custom snooze)" then we set the context menu chat id to null
|
||||
// Else we do nothing and its handled in the ChatList.vue hideCustomSnoozeModal() method
|
||||
if (
|
||||
selectedSnoozeType.value !== wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME
|
||||
) {
|
||||
store.dispatch('setContextMenuChatId', null);
|
||||
}
|
||||
resetSnoozeState();
|
||||
};
|
||||
|
||||
watchEffect(() => {
|
||||
@@ -196,10 +78,7 @@ watchEffect(() => {
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
setCommandBarData();
|
||||
patchNinjaKeysOpenClose(ninjakeys.value);
|
||||
});
|
||||
onMounted(setCommandBarData);
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/attribute-hyphenation -->
|
||||
@@ -209,7 +88,6 @@ onMounted(() => {
|
||||
noAutoLoadMdIcons
|
||||
hideBreadcrumbs
|
||||
:placeholder="placeholder"
|
||||
@change="onCommandBarChange"
|
||||
@selected="onSelected"
|
||||
@closed="onClosed"
|
||||
/>
|
||||
|
||||
+9
-1
@@ -11,7 +11,10 @@ const store = useStore();
|
||||
|
||||
const pageNumber = ref(1);
|
||||
|
||||
const articles = useMapGetter('articles/allArticles');
|
||||
const allArticles = useMapGetter('articles/allArticles');
|
||||
const articlesSortedByPosition = useMapGetter(
|
||||
'articles/allArticlesSortedByPosition'
|
||||
);
|
||||
const categories = useMapGetter('categories/allCategories');
|
||||
const meta = useMapGetter('articles/getMeta');
|
||||
const portalMeta = useMapGetter('portals/getMeta');
|
||||
@@ -58,6 +61,11 @@ const isCategoryArticles = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
// Use position-sorted articles for category views and categories filter view (where drag reorder is enabled)
|
||||
const articles = computed(() =>
|
||||
isCategoryArticles.value ? articlesSortedByPosition.value : allArticles.value
|
||||
);
|
||||
|
||||
const fetchArticles = ({ pageNumber: pageNumberParam } = {}) => {
|
||||
store.dispatch('articles/index', {
|
||||
pageNumber: pageNumberParam || pageNumber.value,
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import CategoriesPage from 'dashboard/components-next/HelpCenter/Pages/CategoryP
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
|
||||
const categories = useMapGetter('categories/allCategories');
|
||||
const categories = useMapGetter('categories/allCategoriesSortedByPosition');
|
||||
|
||||
const selectedPortalSlug = computed(() => route.params.portalSlug);
|
||||
const getPortalBySlug = useMapGetter('portals/portalBySlug');
|
||||
|
||||
@@ -34,6 +34,9 @@ export default {
|
||||
},
|
||||
},
|
||||
emits: ['next', 'prev'],
|
||||
data() {
|
||||
return { showCustomSnoozeModal: false };
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ meta: 'notifications/getMeta' }),
|
||||
},
|
||||
@@ -48,6 +51,9 @@ export default {
|
||||
const ninja = document.querySelector('ninja-keys');
|
||||
ninja.open({ parent: 'snooze_notification' });
|
||||
},
|
||||
hideCustomSnoozeModal() {
|
||||
this.showCustomSnoozeModal = false;
|
||||
},
|
||||
async snoozeNotification(snoozedUntil) {
|
||||
try {
|
||||
await this.$store.dispatch('notifications/snooze', {
|
||||
@@ -62,15 +68,14 @@ export default {
|
||||
},
|
||||
onCmdSnoozeNotification(snoozeType) {
|
||||
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
this.$refs.snoozeModalRef?.open();
|
||||
} else if (typeof snoozeType === 'number') {
|
||||
this.snoozeNotification(snoozeType);
|
||||
this.showCustomSnoozeModal = true;
|
||||
} else {
|
||||
const snoozedUntil = findSnoozeTime(snoozeType) || null;
|
||||
this.snoozeNotification(snoozedUntil);
|
||||
}
|
||||
},
|
||||
scheduleCustomSnooze(customSnoozeTime) {
|
||||
this.showCustomSnoozeModal = false;
|
||||
if (customSnoozeTime) {
|
||||
const snoozedUntil = getUnixTime(customSnoozeTime) || null;
|
||||
this.snoozeNotification(snoozedUntil);
|
||||
@@ -140,9 +145,14 @@ export default {
|
||||
@click="deleteNotification"
|
||||
/>
|
||||
</div>
|
||||
<CustomSnoozeModal
|
||||
ref="snoozeModalRef"
|
||||
@choose-time="scheduleCustomSnooze"
|
||||
/>
|
||||
<woot-modal
|
||||
v-model:show="showCustomSnoozeModal"
|
||||
:on-close="hideCustomSnoozeModal"
|
||||
>
|
||||
<CustomSnoozeModal
|
||||
@close="hideCustomSnoozeModal"
|
||||
@choose-time="scheduleCustomSnooze"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -160,6 +160,7 @@ export default {
|
||||
@submit.prevent="updateAccount"
|
||||
>
|
||||
<WithLabel
|
||||
name="account-name"
|
||||
:has-error="v$.name.$error"
|
||||
:label="$t('GENERAL_SETTINGS.FORM.NAME.LABEL')"
|
||||
:error-message="$t('GENERAL_SETTINGS.FORM.NAME.ERROR')"
|
||||
@@ -173,6 +174,7 @@ export default {
|
||||
/>
|
||||
</WithLabel>
|
||||
<WithLabel
|
||||
name="site-language"
|
||||
:has-error="v$.locale.$error"
|
||||
:label="$t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL')"
|
||||
:error-message="$t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR')"
|
||||
@@ -189,6 +191,7 @@ export default {
|
||||
</WithLabel>
|
||||
<WithLabel
|
||||
v-if="featureCustomReplyDomainEnabled"
|
||||
name="custom-domain"
|
||||
:label="$t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL')"
|
||||
>
|
||||
<NextInput
|
||||
@@ -211,6 +214,7 @@ export default {
|
||||
</WithLabel>
|
||||
<WithLabel
|
||||
v-if="featureCustomReplyEmailEnabled"
|
||||
name="support-email"
|
||||
:label="$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL')"
|
||||
>
|
||||
<NextInput
|
||||
|
||||
+3
-3
@@ -14,13 +14,13 @@ import {
|
||||
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
|
||||
import ActiveFilterChip from '../Filters/v3/ActiveFilterChip.vue';
|
||||
import AddFilterChip from '../Filters/v3/AddFilterChip.vue';
|
||||
import DateRangePicker from 'dashboard/components-next/DatePicker/DateRangePicker.vue';
|
||||
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
|
||||
import {
|
||||
parseReportURLParams,
|
||||
parseFilterURLParams,
|
||||
generateCompleteURLParams,
|
||||
} from '../../helpers/reportFilterHelper';
|
||||
import { DATE_RANGE_TYPES } from 'dashboard/components-next/DatePicker/helpers/DatePickerHelper.js';
|
||||
import { DATE_RANGE_TYPES } from 'dashboard/components/ui/DatePicker/helpers/DatePickerHelper';
|
||||
|
||||
const props = defineProps({
|
||||
showTeamFilter: {
|
||||
@@ -254,7 +254,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col flex-wrap w-full gap-3 md:flex-row">
|
||||
<DateRangePicker
|
||||
<WootDatePicker
|
||||
v-model:date-range="customDateRange"
|
||||
v-model:range-type="selectedDateRange"
|
||||
@date-range-changed="onDateRangeChange"
|
||||
|
||||
+3
-3
@@ -3,13 +3,13 @@ import { ref, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import DateRangePicker from 'dashboard/components-next/DatePicker/DateRangePicker.vue';
|
||||
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import {
|
||||
generateReportURLParams,
|
||||
parseReportURLParams,
|
||||
} from '../helpers/reportFilterHelper';
|
||||
import { DATE_RANGE_TYPES } from 'dashboard/components-next/DatePicker/helpers/DatePickerHelper.js';
|
||||
import { DATE_RANGE_TYPES } from 'dashboard/components/ui/DatePicker/helpers/DatePickerHelper';
|
||||
|
||||
defineProps({
|
||||
disabled: {
|
||||
@@ -91,7 +91,7 @@ onMounted(() => {
|
||||
:class="{ 'pointer-events-none opacity-50': disabled }"
|
||||
>
|
||||
<div class="flex flex-col flex-wrap items-start gap-2 md:flex-row">
|
||||
<DateRangePicker
|
||||
<WootDatePicker
|
||||
v-model:date-range="customDateRange"
|
||||
v-model:range-type="selectedDateRange"
|
||||
@date-range-changed="onDateRangeChange"
|
||||
|
||||
+3
-3
@@ -7,10 +7,10 @@ import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import differenceInDays from 'date-fns/differenceInDays';
|
||||
import ActiveFilterChip from './Filters/v3/ActiveFilterChip.vue';
|
||||
import DateRangePicker from 'dashboard/components-next/DatePicker/DateRangePicker.vue';
|
||||
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import { GROUP_BY_FILTER } from '../constants';
|
||||
import { DATE_RANGE_TYPES } from 'dashboard/components-next/DatePicker/helpers/DatePickerHelper.js';
|
||||
import { DATE_RANGE_TYPES } from 'dashboard/components/ui/DatePicker/helpers/DatePickerHelper';
|
||||
import {
|
||||
generateReportURLParams,
|
||||
parseReportURLParams,
|
||||
@@ -320,7 +320,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col w-full gap-3 lg:flex-row">
|
||||
<DateRangePicker
|
||||
<WootDatePicker
|
||||
v-model:date-range="customDateRange"
|
||||
v-model:range-type="selectedDateRange"
|
||||
@date-range-changed="onDateRangeChange"
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import SLAFilter from '../SLA/SLAFilter.vue';
|
||||
import DateRangePicker from 'dashboard/components-next/DatePicker/DateRangePicker.vue';
|
||||
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
|
||||
import { subDays, fromUnixTime } from 'date-fns';
|
||||
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
|
||||
import {
|
||||
@@ -88,7 +88,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col flex-wrap w-full gap-3 md:flex-row">
|
||||
<DateRangePicker
|
||||
<WootDatePicker
|
||||
v-model:date-range="customDateRange"
|
||||
v-model:range-type="selectedDateRange"
|
||||
@date-range-changed="onDateRangeChange"
|
||||
|
||||
@@ -167,7 +167,17 @@ export const actions = {
|
||||
return fileUrl;
|
||||
},
|
||||
|
||||
reorder: async (_, { portalSlug, categorySlug, reorderedGroup }) => {
|
||||
reorder: async (
|
||||
{ commit, state },
|
||||
{ portalSlug, categorySlug, reorderedGroup }
|
||||
) => {
|
||||
// Save old positions so we can rollback on failure
|
||||
const oldPositions = Object.keys(reorderedGroup).reduce((map, id) => {
|
||||
map[id] = state.articles.byId[id]?.position;
|
||||
return map;
|
||||
}, {});
|
||||
// Update positions in the store immediately so subsequent mutations preserve correct positions
|
||||
commit(types.SET_ARTICLE_POSITIONS, reorderedGroup);
|
||||
try {
|
||||
await articlesAPI.reorderArticles({
|
||||
portalSlug,
|
||||
@@ -175,9 +185,8 @@ export const actions = {
|
||||
categorySlug,
|
||||
});
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
commit(types.SET_ARTICLE_POSITIONS, oldPositions);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,6 +22,16 @@ export const getters = {
|
||||
.filter(article => article !== undefined);
|
||||
return articles;
|
||||
},
|
||||
allArticlesSortedByPosition: (...getterArguments) => {
|
||||
const [state, _getters] = getterArguments;
|
||||
const articles = state.articles.allIds
|
||||
.map(id => _getters.articleById(id))
|
||||
.filter(article => article !== undefined);
|
||||
// Sort by position so reordered articles stay in correct order after store updates
|
||||
return articles.sort(
|
||||
(a, b) => (a.position ?? Infinity) - (b.position ?? Infinity)
|
||||
);
|
||||
},
|
||||
articleStatus:
|
||||
(...getterArguments) =>
|
||||
articleId => {
|
||||
|
||||
@@ -64,6 +64,18 @@ export const mutations = {
|
||||
...uiFlags,
|
||||
};
|
||||
},
|
||||
[types.SET_ARTICLE_POSITIONS]: ($state, positionsHash) => {
|
||||
const { byId, allIds } = $state.articles;
|
||||
// Update position on each article record
|
||||
Object.entries(positionsHash).forEach(([id, position]) => {
|
||||
if (byId[id]) byId[id] = { ...byId[id], position };
|
||||
});
|
||||
// Re-sort allIds so every consumer sees the new order
|
||||
allIds.sort(
|
||||
(a, b) =>
|
||||
(byId[a]?.position ?? Infinity) - (byId[b]?.position ?? Infinity)
|
||||
);
|
||||
},
|
||||
[types.UPDATE_ARTICLE]: ($state, updatedArticle) => {
|
||||
const articleId = updatedArticle.id;
|
||||
if ($state.articles.byId[articleId]) {
|
||||
|
||||
@@ -279,4 +279,63 @@ describe('#actions', () => {
|
||||
).rejects.toThrow('Upload failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#reorder', () => {
|
||||
const state = {
|
||||
articles: {
|
||||
byId: {
|
||||
1: { id: 1, title: 'Article 1', position: 10 },
|
||||
2: { id: 2, title: 'Article 2', position: 20 },
|
||||
3: { id: 3, title: 'Article 3', position: 30 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('commits SET_ARTICLE_POSITIONS and calls API when reorder is successful', async () => {
|
||||
axios.post.mockResolvedValue({ data: {} });
|
||||
const reorderedGroup = { 1: 1, 2: 2, 3: 3 };
|
||||
|
||||
await actions.reorder(
|
||||
{ commit, state },
|
||||
{
|
||||
portalSlug: 'test-portal',
|
||||
categorySlug: 'test-category',
|
||||
reorderedGroup,
|
||||
}
|
||||
);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(
|
||||
types.default.SET_ARTICLE_POSITIONS,
|
||||
reorderedGroup
|
||||
);
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/portals/test-portal/articles/reorder'),
|
||||
{ positions_hash: reorderedGroup, category_slug: 'test-category' }
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back positions and throws when API call fails', async () => {
|
||||
axios.post.mockRejectedValue({ message: 'Network error' });
|
||||
const reorderedGroup = { 1: 1, 2: 2 };
|
||||
|
||||
await expect(
|
||||
actions.reorder(
|
||||
{ commit, state },
|
||||
{
|
||||
portalSlug: 'test-portal',
|
||||
reorderedGroup,
|
||||
}
|
||||
)
|
||||
).rejects.toEqual({ message: 'Network error' });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(
|
||||
types.default.SET_ARTICLE_POSITIONS,
|
||||
reorderedGroup
|
||||
);
|
||||
expect(commit).toHaveBeenCalledWith(types.default.SET_ARTICLE_POSITIONS, {
|
||||
1: 10,
|
||||
2: 20,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,4 +41,82 @@ describe('#getters', () => {
|
||||
it('isFetchingArticles', () => {
|
||||
expect(getters.isFetching(state)).toEqual(true);
|
||||
});
|
||||
|
||||
describe('allArticlesSortedByPosition', () => {
|
||||
it('returns articles sorted by position in ascending order', () => {
|
||||
const stateWithPositions = {
|
||||
...state,
|
||||
articles: {
|
||||
...state.articles,
|
||||
byId: {
|
||||
1: { id: 1, title: 'Article 1', position: 3 },
|
||||
2: { id: 2, title: 'Article 2', position: 1 },
|
||||
3: { id: 3, title: 'Article 3', position: 2 },
|
||||
},
|
||||
allIds: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const boundGetters = {
|
||||
articleById: getters.articleById(stateWithPositions),
|
||||
};
|
||||
|
||||
const result = getters.allArticlesSortedByPosition(
|
||||
stateWithPositions,
|
||||
boundGetters
|
||||
);
|
||||
|
||||
expect(result.map(a => a.id)).toEqual([2, 3, 1]);
|
||||
expect(result.map(a => a.position)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('places articles with null position at the end', () => {
|
||||
const stateWithNullPositions = {
|
||||
...state,
|
||||
articles: {
|
||||
...state.articles,
|
||||
byId: {
|
||||
1: { id: 1, title: 'Article 1', position: 1 },
|
||||
2: { id: 2, title: 'Article 2', position: null },
|
||||
3: { id: 3, title: 'Article 3', position: 2 },
|
||||
},
|
||||
allIds: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const boundGetters = {
|
||||
articleById: getters.articleById(stateWithNullPositions),
|
||||
};
|
||||
|
||||
const result = getters.allArticlesSortedByPosition(
|
||||
stateWithNullPositions,
|
||||
boundGetters
|
||||
);
|
||||
|
||||
expect(result.map(a => a.id)).toEqual([1, 3, 2]);
|
||||
});
|
||||
|
||||
it('handles articles with undefined position', () => {
|
||||
const stateWithUndefinedPositions = {
|
||||
...state,
|
||||
articles: {
|
||||
...state.articles,
|
||||
byId: {
|
||||
1: { id: 1, title: 'Article 1', position: 1 },
|
||||
2: { id: 2, title: 'Article 2' },
|
||||
3: { id: 3, title: 'Article 3', position: 2 },
|
||||
},
|
||||
allIds: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const boundGetters = {
|
||||
articleById: getters.articleById(stateWithUndefinedPositions),
|
||||
};
|
||||
|
||||
const result = getters.allArticlesSortedByPosition(
|
||||
stateWithUndefinedPositions,
|
||||
boundGetters
|
||||
);
|
||||
|
||||
expect(result.map(a => a.id)).toEqual([1, 3, 2]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import types from '../../../mutation-types';
|
||||
describe('#mutations', () => {
|
||||
let state = {};
|
||||
beforeEach(() => {
|
||||
state = article;
|
||||
state = JSON.parse(JSON.stringify(article));
|
||||
});
|
||||
|
||||
describe('#SET_UI_FLAG', () => {
|
||||
@@ -93,9 +93,9 @@ describe('#mutations', () => {
|
||||
mutations[types.ADD_ARTICLE_ID](state, 3);
|
||||
expect(state.articles.allIds).toEqual([1, 2, 3]);
|
||||
});
|
||||
it('Does not invalid article with empty data passed', () => {
|
||||
mutations[types.ADD_ARTICLE_ID](state, {});
|
||||
expect(state).toEqual(article);
|
||||
it('does not add duplicate article id to state', () => {
|
||||
mutations[types.ADD_ARTICLE_ID](state, 1);
|
||||
expect(state.articles.allIds).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,4 +154,53 @@ describe('#mutations', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#SET_ARTICLE_POSITIONS', () => {
|
||||
it('updates positions for articles in the store', () => {
|
||||
const positionsHash = { 1: 1, 2: 2 };
|
||||
mutations[types.SET_ARTICLE_POSITIONS](state, positionsHash);
|
||||
|
||||
expect(state.articles.byId[1].position).toEqual(1);
|
||||
expect(state.articles.byId[2].position).toEqual(2);
|
||||
});
|
||||
|
||||
it('does not update articles that are not in the store', () => {
|
||||
const positionsHash = { 999: 5 };
|
||||
mutations[types.SET_ARTICLE_POSITIONS](state, positionsHash);
|
||||
|
||||
expect(state.articles.byId[999]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves other article properties when updating position', () => {
|
||||
const originalTitle = state.articles.byId[1].title;
|
||||
const positionsHash = { 1: 3 };
|
||||
mutations[types.SET_ARTICLE_POSITIONS](state, positionsHash);
|
||||
|
||||
expect(state.articles.byId[1].position).toEqual(3);
|
||||
expect(state.articles.byId[1].title).toEqual(originalTitle);
|
||||
});
|
||||
|
||||
it('re-sorts allIds by position after update', () => {
|
||||
state.articles.byId[1].position = 1;
|
||||
state.articles.byId[2].position = 2;
|
||||
state.articles.allIds = [1, 2];
|
||||
|
||||
mutations[types.SET_ARTICLE_POSITIONS](state, { 1: 3, 2: 1 });
|
||||
|
||||
expect(state.articles.allIds).toEqual([2, 1]);
|
||||
});
|
||||
|
||||
it('UPDATE_ARTICLE preserves reordered position after SET_ARTICLE_POSITIONS', () => {
|
||||
mutations[types.SET_ARTICLE_POSITIONS](state, { 2: 1 });
|
||||
expect(state.articles.byId[2].position).toEqual(1);
|
||||
|
||||
mutations[types.UPDATE_ARTICLE](state, {
|
||||
id: 2,
|
||||
title: 'Updated Title',
|
||||
status: 'published',
|
||||
});
|
||||
expect(state.articles.byId[2].position).toEqual(1);
|
||||
expect(state.articles.byId[2].title).toEqual('Updated Title');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,4 +92,23 @@ export const actions = {
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
reorder: async ({ commit, state }, { portalSlug, reorderedGroup }) => {
|
||||
// Save old positions so we can rollback on failure
|
||||
const oldPositions = Object.keys(reorderedGroup).reduce((map, id) => {
|
||||
map[id] = state.categories.byId[id]?.position;
|
||||
return map;
|
||||
}, {});
|
||||
// Update positions in the store immediately so subsequent mutations preserve correct positions
|
||||
commit(types.SET_CATEGORY_POSITIONS, reorderedGroup);
|
||||
try {
|
||||
await categoriesAPI.reorder({
|
||||
portalSlug,
|
||||
reorderedGroup,
|
||||
});
|
||||
} catch (error) {
|
||||
commit(types.SET_CATEGORY_POSITIONS, oldPositions);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -21,6 +21,16 @@ export const getters = {
|
||||
});
|
||||
return categories;
|
||||
},
|
||||
allCategoriesSortedByPosition: (...getterArguments) => {
|
||||
const [state, _getters] = getterArguments;
|
||||
const categories = state.categories.allIds
|
||||
.map(id => _getters.categoryById(id))
|
||||
.filter(category => category !== undefined);
|
||||
// Sort by position so reordered categories stay in correct order after store updates
|
||||
return categories.sort(
|
||||
(a, b) => (a.position ?? Infinity) - (b.position ?? Infinity)
|
||||
);
|
||||
},
|
||||
categoriesByLocaleCode:
|
||||
(...getterArguments) =>
|
||||
localeCode => {
|
||||
|
||||
@@ -49,6 +49,18 @@ export const mutations = {
|
||||
...uiFlags,
|
||||
};
|
||||
},
|
||||
[types.SET_CATEGORY_POSITIONS]: ($state, positionsHash) => {
|
||||
const { byId, allIds } = $state.categories;
|
||||
// Update position on each category record
|
||||
Object.entries(positionsHash).forEach(([id, position]) => {
|
||||
if (byId[id]) byId[id] = { ...byId[id], position };
|
||||
});
|
||||
// Re-sort allIds so every consumer sees the new order
|
||||
allIds.sort(
|
||||
(a, b) =>
|
||||
(byId[a]?.position ?? Infinity) - (byId[b]?.position ?? Infinity)
|
||||
);
|
||||
},
|
||||
[types.UPDATE_CATEGORY]($state, category) {
|
||||
const categoryId = category.id;
|
||||
|
||||
|
||||
@@ -161,4 +161,63 @@ describe('#actions', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#reorder', () => {
|
||||
const state = {
|
||||
categories: {
|
||||
byId: {
|
||||
1: { id: 1, name: 'Category 1', position: 10 },
|
||||
2: { id: 2, name: 'Category 2', position: 20 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('commits SET_CATEGORY_POSITIONS and calls API when reorder is successful', async () => {
|
||||
axios.post.mockResolvedValue({ data: {} });
|
||||
const reorderedGroup = { 2: 1, 1: 2 };
|
||||
|
||||
await actions.reorder(
|
||||
{ commit, state },
|
||||
{
|
||||
portalSlug: 'room-rental',
|
||||
reorderedGroup,
|
||||
}
|
||||
);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(
|
||||
types.default.SET_CATEGORY_POSITIONS,
|
||||
reorderedGroup
|
||||
);
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/portals/room-rental/categories/reorder'),
|
||||
{
|
||||
positions_hash: { 2: 1, 1: 2 },
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back positions and throws when API call fails', async () => {
|
||||
axios.post.mockRejectedValue({ message: 'Incorrect header' });
|
||||
const reorderedGroup = { 2: 1, 1: 2 };
|
||||
|
||||
await expect(
|
||||
actions.reorder(
|
||||
{ commit, state },
|
||||
{
|
||||
portalSlug: 'room-rental',
|
||||
reorderedGroup,
|
||||
}
|
||||
)
|
||||
).rejects.toEqual({ message: 'Incorrect header' });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(
|
||||
types.default.SET_CATEGORY_POSITIONS,
|
||||
reorderedGroup
|
||||
);
|
||||
expect(commit).toHaveBeenCalledWith(
|
||||
types.default.SET_CATEGORY_POSITIONS,
|
||||
{ 1: 10, 2: 20 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,4 +25,82 @@ describe('#getters', () => {
|
||||
it('isFetchingCategories', () => {
|
||||
expect(getters.isFetching(state)).toEqual(true);
|
||||
});
|
||||
|
||||
describe('allCategoriesSortedByPosition', () => {
|
||||
it('returns categories sorted by position in ascending order', () => {
|
||||
const stateWithPositions = {
|
||||
...state,
|
||||
categories: {
|
||||
...state.categories,
|
||||
byId: {
|
||||
1: { id: 1, name: 'Category 1', position: 3 },
|
||||
2: { id: 2, name: 'Category 2', position: 1 },
|
||||
3: { id: 3, name: 'Category 3', position: 2 },
|
||||
},
|
||||
allIds: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const boundGetters = {
|
||||
categoryById: getters.categoryById(stateWithPositions),
|
||||
};
|
||||
|
||||
const result = getters.allCategoriesSortedByPosition(
|
||||
stateWithPositions,
|
||||
boundGetters
|
||||
);
|
||||
|
||||
expect(result.map(c => c.id)).toEqual([2, 3, 1]);
|
||||
expect(result.map(c => c.position)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('places categories with null position at the end', () => {
|
||||
const stateWithNullPositions = {
|
||||
...state,
|
||||
categories: {
|
||||
...state.categories,
|
||||
byId: {
|
||||
1: { id: 1, name: 'Category 1', position: 1 },
|
||||
2: { id: 2, name: 'Category 2', position: null },
|
||||
3: { id: 3, name: 'Category 3', position: 2 },
|
||||
},
|
||||
allIds: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const boundGetters = {
|
||||
categoryById: getters.categoryById(stateWithNullPositions),
|
||||
};
|
||||
|
||||
const result = getters.allCategoriesSortedByPosition(
|
||||
stateWithNullPositions,
|
||||
boundGetters
|
||||
);
|
||||
|
||||
expect(result.map(c => c.id)).toEqual([1, 3, 2]);
|
||||
});
|
||||
|
||||
it('handles categories with undefined position', () => {
|
||||
const stateWithUndefinedPositions = {
|
||||
...state,
|
||||
categories: {
|
||||
...state.categories,
|
||||
byId: {
|
||||
1: { id: 1, name: 'Category 1', position: 1 },
|
||||
2: { id: 2, name: 'Category 2' },
|
||||
3: { id: 3, name: 'Category 3', position: 2 },
|
||||
},
|
||||
allIds: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const boundGetters = {
|
||||
categoryById: getters.categoryById(stateWithUndefinedPositions),
|
||||
};
|
||||
|
||||
const result = getters.allCategoriesSortedByPosition(
|
||||
stateWithUndefinedPositions,
|
||||
boundGetters
|
||||
);
|
||||
|
||||
expect(result.map(c => c.id)).toEqual([1, 3, 2]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+39
-3
@@ -4,7 +4,7 @@ import { categoriesState, categoriesPayload } from './fixtures';
|
||||
describe('#mutations', () => {
|
||||
let state = {};
|
||||
beforeEach(() => {
|
||||
state = categoriesState;
|
||||
state = JSON.parse(JSON.stringify(categoriesState));
|
||||
});
|
||||
|
||||
describe('#SET_UI_FLAG', () => {
|
||||
@@ -53,9 +53,9 @@ describe('#mutations', () => {
|
||||
mutations[types.ADD_CATEGORY_ID](state, 3);
|
||||
expect(state.categories.allIds).toEqual([1, 2, 3]);
|
||||
});
|
||||
it('Does not invalid category with empty data passed', () => {
|
||||
it('pushes the given id to allIds', () => {
|
||||
mutations[types.ADD_CATEGORY_ID](state, {});
|
||||
expect(state).toEqual(categoriesState);
|
||||
expect(state.categories.allIds).toEqual([1, 2, {}]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,4 +98,40 @@ describe('#mutations', () => {
|
||||
// expect(state.categories.uiFlags).toEqual({});
|
||||
// });
|
||||
// });
|
||||
|
||||
describe('#SET_CATEGORY_POSITIONS', () => {
|
||||
it('updates positions for categories in the store', () => {
|
||||
const positionsHash = { 1: 1, 2: 2 };
|
||||
mutations[types.SET_CATEGORY_POSITIONS](state, positionsHash);
|
||||
|
||||
expect(state.categories.byId[1].position).toEqual(1);
|
||||
expect(state.categories.byId[2].position).toEqual(2);
|
||||
});
|
||||
|
||||
it('does not update categories that are not in the store', () => {
|
||||
const positionsHash = { 999: 5 };
|
||||
mutations[types.SET_CATEGORY_POSITIONS](state, positionsHash);
|
||||
|
||||
expect(state.categories.byId[999]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves other category properties when updating position', () => {
|
||||
const originalName = state.categories.byId[1].name;
|
||||
const positionsHash = { 1: 3 };
|
||||
mutations[types.SET_CATEGORY_POSITIONS](state, positionsHash);
|
||||
|
||||
expect(state.categories.byId[1].position).toEqual(3);
|
||||
expect(state.categories.byId[1].name).toEqual(originalName);
|
||||
});
|
||||
|
||||
it('re-sorts allIds by position after update', () => {
|
||||
state.categories.byId[1].position = 1;
|
||||
state.categories.byId[2].position = 2;
|
||||
state.categories.allIds = [1, 2];
|
||||
|
||||
mutations[types.SET_CATEGORY_POSITIONS](state, { 1: 3, 2: 1 });
|
||||
|
||||
expect(state.categories.allIds).toEqual([2, 1]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -290,6 +290,7 @@ export default {
|
||||
REMOVE_ARTICLE: 'REMOVE_ARTICLE',
|
||||
REMOVE_ARTICLE_ID: 'REMOVE_ARTICLE_ID',
|
||||
SET_UI_FLAG: 'SET_UI_FLAG',
|
||||
SET_ARTICLE_POSITIONS: 'SET_ARTICLE_POSITIONS',
|
||||
|
||||
// Help Center -- Categories
|
||||
ADD_CATEGORY: 'ADD_CATEGORY',
|
||||
@@ -301,6 +302,7 @@ export default {
|
||||
UPDATE_CATEGORY: 'UPDATE_CATEGORY',
|
||||
REMOVE_CATEGORY: 'REMOVE_CATEGORY',
|
||||
REMOVE_CATEGORY_ID: 'REMOVE_CATEGORY_ID',
|
||||
SET_CATEGORY_POSITIONS: 'SET_CATEGORY_POSITIONS',
|
||||
|
||||
// Agent Bots
|
||||
SET_AGENT_BOT_UI_FLAG: 'SET_AGENT_BOT_UI_FLAG',
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
class Avatar::AvatarFromFaviconJob < ApplicationJob
|
||||
queue_as :purgable
|
||||
|
||||
def perform(company)
|
||||
return if company.domain.blank?
|
||||
return if company.avatar.attached?
|
||||
|
||||
favicon_url = "https://www.google.com/s2/favicons?domain=#{company.domain}&sz=256"
|
||||
Avatar::AvatarFromUrlJob.perform_now(company, favicon_url)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
class Companies::FetchAvatarsJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(account_id)
|
||||
account = Account.find(account_id)
|
||||
companies = account.companies.where.not(domain: [nil, ''])
|
||||
.left_joins(:avatar_attachment)
|
||||
.where(active_storage_attachments: { id: nil })
|
||||
|
||||
total_companies = companies.count
|
||||
companies.find_each do |company|
|
||||
Avatar::AvatarFromFaviconJob.perform_later(company)
|
||||
end
|
||||
|
||||
Rails.logger.info "Queued #{total_companies} companies from account #{account_id} for favicon fetch"
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,6 @@
|
||||
class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
class Webhooks::WhatsappEventsJob < MutexApplicationJob
|
||||
queue_as :low
|
||||
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
|
||||
|
||||
def perform(params = {})
|
||||
channel = find_channel_from_whatsapp_business_payload(params)
|
||||
@@ -9,10 +10,14 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
return
|
||||
end
|
||||
|
||||
if message_echo_event?(params)
|
||||
handle_message_echo(channel, params)
|
||||
sender_id = extract_sender_id(params)
|
||||
if sender_id
|
||||
key = format(::Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, sender_id: sender_id, phone_number: channel.phone_number)
|
||||
with_lock(key, 30.seconds) do
|
||||
process_events(channel, params)
|
||||
end
|
||||
else
|
||||
handle_message_events(channel, params)
|
||||
process_events(channel, params)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -54,8 +59,14 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
params.dig(:entry, 0, :changes, 0, :field) == 'smb_message_echoes'
|
||||
end
|
||||
|
||||
def handle_message_echo(channel, params)
|
||||
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params, outgoing_echo: true).perform
|
||||
private
|
||||
|
||||
def process_events(channel, params)
|
||||
if message_echo_event?(params)
|
||||
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params, outgoing_echo: true).perform
|
||||
else
|
||||
handle_message_events(channel, params)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_message_events(channel, params)
|
||||
@@ -67,7 +78,16 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
# Extracts the contact's phone number from the webhook payload for use as a mutex key.
|
||||
# For incoming messages, the contact is the sender (from field).
|
||||
# For echo messages, the contact is the recipient (to field).
|
||||
# For status updates, returns nil (no lock needed as they don't create conversations).
|
||||
def extract_sender_id(params)
|
||||
value = params.dig(:entry, 0, :changes, 0, :value)
|
||||
return unless value
|
||||
|
||||
value.dig(:messages, 0, :from) || value.dig(:contacts, 0, :wa_id) || value.dig(:message_echoes, 0, :to)
|
||||
end
|
||||
|
||||
def channel_is_inactive?(channel)
|
||||
return true if channel.blank?
|
||||
|
||||
@@ -180,8 +180,14 @@ class ActionCableListener < BaseListener
|
||||
end
|
||||
|
||||
def typing_event_listener_tokens(account, conversation, user)
|
||||
current_user_token = user.is_a?(Contact) ? conversation.contact_inbox.pubsub_token : user.pubsub_token
|
||||
(user_tokens(account, conversation.inbox.members) + [conversation.contact_inbox.pubsub_token]) - [current_user_token]
|
||||
current_user_token = if user.is_a?(Contact)
|
||||
conversation.contact_inbox.pubsub_token
|
||||
elsif user.respond_to?(:pubsub_token)
|
||||
user.pubsub_token
|
||||
end
|
||||
|
||||
tokens = user_tokens(account, conversation.inbox.members) + [conversation.contact_inbox.pubsub_token]
|
||||
current_user_token.present? ? tokens - [current_user_token] : tokens
|
||||
end
|
||||
|
||||
def user_tokens(account, agents)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user