DEV: Convert group timezones from widgets to glimmer (#731)

Also introduces a basic system spec for the feature (previously there was no test coverage of the frontend at all)
This commit is contained in:
David Taylor 2025-05-28 11:36:13 +01:00 committed by GitHub
parent 8152a0ca7c
commit d471bbdf9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 260 additions and 414 deletions

View File

@ -0,0 +1,35 @@
import { apiInitializer } from "discourse/lib/api";
import GroupTimezones from "../components/group-timezones";
const GroupTimezonesShim = <template>
<GroupTimezones
@members={{@data.members}}
@group={{@data.group}}
@size={{@data.size}}
/>
</template>;
export default apiInitializer((api) => {
api.decorateCookedElement((element, helper) => {
element.querySelectorAll(".group-timezones").forEach((el) => {
const post = helper.getModel();
if (!post) {
return;
}
const group = el.dataset.group;
if (!group) {
throw new Error(
"Group timezone element is missing 'data-group' attribute"
);
}
helper.renderGlimmer(el, GroupTimezonesShim, {
group,
members: (post.group_timezones || {})[group] || [],
size: el.dataset.size || "medium",
});
});
});
});

View File

@ -1,38 +1,27 @@
import hbs from "discourse/widgets/hbs-compiler";
import { createWidget } from "discourse/widgets/widget";
import roundTime from "../lib/round-time";
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { eq } from "truth-helpers";
import { i18n } from "discourse-i18n";
import roundTime from "../../lib/round-time";
import NewDay from "./new-day";
import TimeTraveller from "./time-traveller";
import Timezone from "./timezone";
export default createWidget("discourse-group-timezones", {
tagName: "div.group-timezones",
export default class GroupTimezones extends Component {
@service siteSettings;
buildKey: (attrs) => `group-timezones-${attrs.id}`,
@tracked filter = "";
@tracked localTimeOffset = 0;
buildClasses(attrs) {
return attrs.size;
},
buildAttributes(attrs) {
return {
id: attrs.id,
};
},
defaultState() {
return {
localTimeOffset: 0,
};
},
onChangeCurrentUserTimeOffset(offset) {
this.state.localTimeOffset = offset;
},
transform(attrs, state) {
const members = attrs.members || [];
get groupedTimezones() {
let groupedTimezones = [];
members.filterBy("timezone").forEach((member) => {
if (this._shouldAddMemberToGroup(this.state.filter, member)) {
this.args.members.filterBy("timezone").forEach((member) => {
if (this.#shouldAddMemberToGroup(this.filter, member)) {
const timezone = member.timezone;
const identifier = parseInt(moment.tz(timezone).format("YYYYMDHm"), 10);
let groupedTimezone = groupedTimezones.findBy("identifier", identifier);
@ -40,18 +29,18 @@ export default createWidget("discourse-group-timezones", {
if (groupedTimezone) {
groupedTimezone.members.push(member);
} else {
const now = this._roundMoment(moment.tz(timezone));
const workingDays = this._workingDays();
const now = this.#roundMoment(moment.tz(timezone));
const workingDays = this.#workingDays();
const offset = moment.tz(moment.utc(), timezone).utcOffset();
groupedTimezone = {
identifier,
offset,
type: "discourse-group-timezone",
nowWithOffset: now.add(state.localTimeOffset, "minutes"),
closeToWorkingHours: this._closeToWorkingHours(now, workingDays),
inWorkingHours: this._inWorkingHours(now, workingDays),
utcOffset: this._utcOffset(offset),
nowWithOffset: now.add(this.localTimeOffset, "minutes"),
closeToWorkingHours: this.#closeToWorkingHours(now, workingDays),
inWorkingHours: this.#inWorkingHours(now, workingDays),
utcOffset: this.#utcOffset(offset),
members: [member],
};
groupedTimezones.push(groupedTimezone);
@ -84,36 +73,10 @@ export default createWidget("discourse-group-timezones", {
});
}
return { groupedTimezones };
},
return groupedTimezones;
}
onChangeFilter(filter) {
this.state.filter = filter && filter.length ? filter : null;
},
template: hbs`
{{attach
widget="discourse-group-timezones-header"
attrs=(hash
id=attrs.id
group=attrs.group
localTimeOffset=state.localTimeOffset
)
}}
<div class="group-timezones-body">
{{#each transformed.groupedTimezones as |groupedTimezone|}}
{{attach
widget=groupedTimezone.type
attrs=(hash
usersOnHoliday=attrs.usersOnHoliday
groupedTimezone=groupedTimezone
)
}}
{{/each}}
</div>
`,
_shouldAddMemberToGroup(filter, member) {
#shouldAddMemberToGroup(filter, member) {
if (filter) {
filter = filter.toLowerCase();
if (
@ -127,17 +90,17 @@ export default createWidget("discourse-group-timezones", {
}
return false;
},
}
_roundMoment(date) {
if (this.state.localTimeOffset) {
#roundMoment(date) {
if (this.localTimeOffset) {
date = roundTime(date);
}
return date;
},
}
_closeToWorkingHours(moment, workingDays) {
#closeToWorkingHours(moment, workingDays) {
const hours = moment.hours();
const startHour = this.siteSettings.working_day_start_hour;
const endHour = this.siteSettings.working_day_end_hour;
@ -148,18 +111,18 @@ export default createWidget("discourse-group-timezones", {
(hours <= Math.min(endHour + extension, 23) && hours >= endHour)) &&
workingDays.includes(moment.isoWeekday())
);
},
}
_inWorkingHours(moment, workingDays) {
#inWorkingHours(moment, workingDays) {
const hours = moment.hours();
return (
hours > this.siteSettings.working_day_start_hour &&
hours < this.siteSettings.working_day_end_hour &&
workingDays.includes(moment.isoWeekday())
);
},
}
_utcOffset(offset) {
#utcOffset(offset) {
const sign = Math.sign(offset) === 1 ? "+" : "-";
offset = Math.abs(offset);
let hours = Math.floor(offset / 60).toString();
@ -170,9 +133,9 @@ export default createWidget("discourse-group-timezones", {
/:00$/,
""
)}`.replace(/-0/, "&nbsp;");
},
}
_workingDays() {
#workingDays() {
const enMoment = moment().locale("en");
const getIsoWeekday = (day) =>
enMoment.localeData()._weekdays.indexOf(day) || 7;
@ -180,5 +143,40 @@ export default createWidget("discourse-group-timezones", {
.split("|")
.filter(Boolean)
.map((x) => getIsoWeekday(x));
},
});
}
@action
handleFilterChange(event) {
this.filter = event.target.value;
}
<template>
<div class="group-timezones-header">
<TimeTraveller
@localTimeOffset={{this.localTimeOffset}}
@setOffset={{fn (mut this.localTimeOffset)}}
/>
<span class="title">
{{i18n "group_timezones.group_availability" group=@group}}
</span>
<input
type="text"
placeholder={{i18n "group_timezones.search"}}
class="group-timezones-filter"
{{on "input" this.handleFilterChange}}
/>
</div>
<div class="group-timezones-body">
{{#each this.groupedTimezones key="identifier" as |groupedTimezone|}}
{{#if (eq groupedTimezone.type "discourse-group-timezone-new-day")}}
<NewDay
@beforeDate={{groupedTimezone.beforeDate}}
@afterDate={{groupedTimezone.afterDate}}
/>
{{else}}
<Timezone @groupedTimezone={{groupedTimezone}} />
{{/if}}
{{/each}}
</div>
</template>
}

View File

@ -0,0 +1,16 @@
import icon from "discourse/helpers/d-icon";
const NewDay = <template>
<div class="group-timezone-new-day">
<span class="before">
{{icon "chevron-left"}}
{{@beforeDate}}
</span>
<span class="after">
{{@afterDate}}
{{icon "chevron-right"}}
</span>
</div>
</template>;
export default NewDay;

View File

@ -0,0 +1,58 @@
import Component from "@glimmer/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { not } from "truth-helpers";
import DButton from "discourse/components/d-button";
import roundTime from "../../lib/round-time";
export default class TimeTraveller extends Component {
get localTimeWithOffset() {
let date = moment().add(this.args.localTimeOffset, "minutes");
if (this.args.localTimeOffset) {
date = roundTime(date);
}
return date.format("HH:mm");
}
@action
reset() {
this.args.setOffset(0);
}
@action
sliderMoved(event) {
const value = parseInt(event.target.value, 10);
const offset = value * 15;
this.args.setOffset(offset);
}
<template>
<div class="group-timezones-time-traveler">
<span class="time">
{{this.localTimeWithOffset}}
</span>
<span class="discourse-group-timezones-slider-wrapper">
<input
class="group-timezones-slider"
{{on "input" this.sliderMoved}}
step="1"
value="0"
type="range"
min="-48"
max="48"
/>
</span>
<div class="group-timezones-reset">
<DButton
disabled={{not @localTimeOffset}}
@action={{this.reset}}
@icon="arrow-rotate-left"
/>
</div>
</div>
</template>
}

View File

@ -0,0 +1,44 @@
import Component from "@glimmer/component";
import UserAvatar from "discourse/components/user-avatar";
import concatClass from "discourse/helpers/concat-class";
export default class GroupTimezone extends Component {
get formattedTime() {
return this.args.groupedTimezone.nowWithOffset.format("LT");
}
<template>
<div
class={{concatClass
"group-timezone"
(if @groupedTimezone.closeToWorkingHours "close-to-working-hours")
(if @groupedTimezone.inWorkingHours "in-working-hours")
}}
>
<div class="info">
<span class="time">
{{this.formattedTime}}
</span>
<span class="offset" title="UTC offset">
{{@groupedTimezone.utcOffset}}
</span>
</div>
<ul class="group-timezones-members">
{{#each @groupedTimezone.members key="username" as |member|}}
<li
class={{concatClass
"group-timezones-member"
(if member.on_holiday "on-holiday" "not-on-holiday")
}}
>
<UserAvatar
@user={{member}}
@size="small"
class="group-timezones-member-avatar"
/>
</li>
{{/each}}
</ul>
</div>
</template>
}

View File

@ -1,72 +0,0 @@
import $ from "jquery";
import { getRegister } from "discourse/lib/get-owner";
import { withPluginApi } from "discourse/lib/plugin-api";
import WidgetGlue from "discourse/widgets/glue";
export default {
name: "discourse-group-timezones",
initialize() {
withPluginApi("0.8.7", (api) => {
let _glued = [];
function cleanUp() {
_glued.forEach((g) => g.cleanUp());
_glued = [];
}
function _attachWidget(container, options) {
const glue = new WidgetGlue(
"discourse-group-timezones",
getRegister(api),
options
);
glue.appendTo(container);
_glued.push(glue);
}
function _attachGroupTimezones($elem, post) {
const $groupTimezones = $(".group-timezones", $elem);
if (!$groupTimezones.length) {
return;
}
$groupTimezones.each((idx, groupTimezone) => {
const group = groupTimezone.getAttribute("data-group");
if (!group) {
throw "[group] attribute is necessary when using timezones.";
}
const members = (post.get("group_timezones") || {})[group] || [];
_attachWidget(groupTimezone, {
id: `${post.id}-${idx}`,
members,
group,
usersOnHoliday:
api.container.lookup("service:site").users_on_holiday || [],
size: groupTimezone.getAttribute("data-size") || "medium",
});
});
}
function _attachPostWithGroupTimezones($elem, helper) {
if (helper) {
const post = helper.getModel();
if (post) {
api.preventCloak(post.id);
_attachGroupTimezones($elem, post);
}
}
}
api.decorateCooked(_attachPostWithGroupTimezones, {
id: "discourse-group-timezones",
});
api.cleanupStream(cleanUp);
});
},
};

View File

@ -1,17 +0,0 @@
import hbs from "discourse/widgets/hbs-compiler";
import { createWidget } from "discourse/widgets/widget";
export default createWidget("discourse-group-timezone-new-day", {
tagName: "div.group-timezone-new-day",
template: hbs`
<span class="before">
{{d-icon "chevron-left"}}
{{this.attrs.groupedTimezone.beforeDate}}
</span>
<span class="after">
{{this.attrs.groupedTimezone.afterDate}}
{{d-icon "chevron-right"}}
</span>
`,
});

View File

@ -1,48 +0,0 @@
import hbs from "discourse/widgets/hbs-compiler";
import { createWidget } from "discourse/widgets/widget";
export default createWidget("discourse-group-timezone", {
tagName: "div.group-timezone",
buildClasses(attrs) {
const classes = [];
if (attrs.groupedTimezone.closeToWorkingHours) {
classes.push("close-to-working-hours");
}
if (attrs.groupedTimezone.inWorkingHours) {
classes.push("in-working-hours");
}
return classes.join(" ");
},
transform(attrs) {
return {
formatedTime: attrs.groupedTimezone.nowWithOffset.format("LT"),
};
},
template: hbs`
<div class="info">
<span class="time">
{{transformed.formatedTime}}
</span>
<span class="offset" title="UTC offset">
{{{attrs.groupedTimezone.utcOffset}}}
</span>
</div>
<ul class="group-timezones-members">
{{#each attrs.groupedTimezone.members as |member|}}
{{attach
widget="discourse-group-timezones-member"
attrs=(hash
usersOnHoliday=attrs.usersOnHoliday
member=member
)
}}
{{/each}}
</ul>
`,
});

View File

@ -1,28 +0,0 @@
import { throttle } from "@ember/runloop";
import { createWidget } from "discourse/widgets/widget";
import { i18n } from "discourse-i18n";
export default createWidget("discourse-group-timezones-filter", {
tagName: "input.group-timezones-filter",
input(event) {
this.changeFilterThrottler(event.target.value);
},
changeFilterThrottler(filter) {
throttle(
this,
function () {
this.sendWidgetAction("onChangeFilter", filter);
},
100
);
},
buildAttributes() {
return {
type: "text",
placeholder: i18n("group_timezones.search"),
};
},
});

View File

@ -1,29 +0,0 @@
import hbs from "discourse/widgets/hbs-compiler";
import { createWidget } from "discourse/widgets/widget";
import { i18n } from "discourse-i18n";
export default createWidget("discourse-group-timezones-header", {
tagName: "div.group-timezones-header",
transform(attrs) {
return {
title: i18n("group_timezones.group_availability", {
group: attrs.group,
}),
};
},
template: hbs`
{{attach
widget="discourse-group-timezones-time-traveler"
attrs=(hash
id=attrs.id
localTimeOffset=attrs.localTimeOffset
)
}}
<span class="title">
{{transformed.title}}
</span>
{{attach widget="discourse-group-timezones-filter"}}
`,
});

View File

@ -1,30 +0,0 @@
import { h } from "virtual-dom";
import { formatUsername } from "discourse/lib/utilities";
import { avatarImg } from "discourse/widgets/post";
import { createWidget } from "discourse/widgets/widget";
export default createWidget("discourse-group-timezones-member", {
tagName: "li.group-timezones-member",
buildClasses(attrs) {
return attrs.member.on_holiday ? "on-holiday" : "not-on-holiday";
},
html(attrs) {
const { name, username, avatar_template } = attrs.member;
return h(
"a",
{
attributes: {
class: "group-timezones-member-avatar",
"data-user-card": username,
},
},
avatarImg("small", {
template: avatar_template,
username: name || formatUsername(username),
})
);
},
});

View File

@ -1,35 +0,0 @@
import hbs from "discourse/widgets/hbs-compiler";
import { createWidget } from "discourse/widgets/widget";
export default createWidget("discourse-group-timezones-reset", {
tagName: "div.group-timezones-reset",
onResetOffset() {
this.sendWidgetAction("onChangeCurrentUserTimeOffset", 0);
const container = document.getElementById(this.attrs.id);
const slider = container.querySelector(
"input[type=range].group-timezones-slider"
);
if (slider) {
slider.value = 0;
}
},
transform(attrs) {
return {
isDisabled: attrs.localTimeOffset === 0,
};
},
template: hbs`
{{attach
widget="button"
attrs=(hash
disabled=this.transformed.isDisabled
action="onResetOffset"
icon="arrow-rotate-left"
)
}}
`,
});

View File

@ -1,40 +0,0 @@
import { throttle } from "@ember/runloop";
import { createWidget } from "discourse/widgets/widget";
export default createWidget("discourse-group-timezones-slider", {
tagName: "input.group-timezones-slider",
input(event) {
this._handleSliderEvent(event);
},
change(event) {
this._handleSliderEvent(event);
},
changeOffsetThrottler(offset) {
throttle(
this,
function () {
this.sendWidgetAction("onChangeCurrentUserTimeOffset", offset);
},
75
);
},
buildAttributes() {
return {
step: 1,
value: 0,
min: -48,
max: 48,
type: "range",
};
},
_handleSliderEvent(event) {
const value = parseInt(event.target.value, 10);
const offset = value * 15;
this.changeOffsetThrottler(offset);
},
});

View File

@ -1,37 +0,0 @@
import hbs from "discourse/widgets/hbs-compiler";
import { createWidget } from "discourse/widgets/widget";
import roundTime from "../lib/round-time";
export default createWidget("discourse-group-timezones-time-traveler", {
tagName: "div.group-timezones-time-traveler",
transform(attrs) {
let date = moment().add(attrs.localTimeOffset, "minutes");
if (attrs.localTimeOffset) {
date = roundTime(date);
}
return {
localTimeWithOffset: date.format("HH:mm"),
};
},
template: hbs`
<span class="time">
{{transformed.localTimeWithOffset}}
</span>
<span class="discourse-group-timezones-slider-wrapper">
{{attach
widget="discourse-group-timezones-slider"
}}
</span>
{{attach
widget="discourse-group-timezones-reset"
attrs=(hash
id=attrs.id
localTimeOffset=attrs.localTimeOffset
)
}}
`,
});

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
describe "Group timezones feature", type: :system do
fab!(:group) { Fabricate(:group, name: "test-group") }
fab!(:users) do
Fabricate
.times(5, :user)
.each do |user|
user.user_option.timezone = "America/New_York"
user.user_option.save!
group.add(user)
end
end
before do
Jobs.run_immediately!
SiteSetting.calendar_enabled = true
end
let(:post) { create_post(raw: <<~RAW) }
[timezones group="test-group"]
[/timezones]
RAW
it "renders successfully" do
visit(post.url)
expect(page).to have_selector(".group-timezones")
expect(page).to have_selector(".group-timezones-member", count: 5)
end
end