1
0
mirror of https://github.com/flarum/core.git synced 2025-08-13 11:54:32 +02:00

Compare commits

...

313 Commits

Author SHA1 Message Date
Franz Liedke
dd17fa3342 Remove unnecessary change 2020-09-18 22:27:11 +02:00
Franz Liedke
db8755d5d9 Remove unrelated change 2020-09-18 22:27:11 +02:00
Franz Liedke
5c2a233b74 Remove unnecessary conditional
The filter(Boolean) covers this.
2020-09-18 22:27:10 +02:00
Alexander Skvortsov
4dd0361ccf Remove unnecessary class name 2020-09-18 22:27:10 +02:00
Alexander Skvortsov
ccd2b0ee0b Revert diff reduction change
Sometimes, items passed to listItems might be null. We still need that filter check
2020-09-18 22:27:10 +02:00
Alexander Skvortsov
b954e79ec8 Move data from vnode to vnode.state
`vnode.state` is persisted, `vnode` might not be
2020-09-18 22:27:09 +02:00
Franz Liedke
bdc64f98a5 I want my small diff back! 2020-09-18 22:27:09 +02:00
Franz Liedke
f9e5aa4193 Email confirmation alert: Simplify implementation
Now that we don't mess with component instances anymore, we can follow
normal Mithril patterns.
2020-09-18 22:27:09 +02:00
Franz Liedke
801220cc10 Fix type annotation 2020-09-18 22:27:08 +02:00
Franz Liedke
bd52aaaa61 Fragment: Update documentation 2020-09-18 22:27:08 +02:00
Franz Liedke
4a5d12626b Revert "Remove deprecated "BC Layers", since BC is broken anyway"
This reverts commit 37c2956efae828e4f25448f4fdff33a79ab0aace.
2020-09-18 22:27:08 +02:00
Franz Liedke
1e4a7f3282 props -> attrs in code 2020-09-18 22:27:07 +02:00
Franz Liedke
cbcb0429d0 props -> attrs in comments 2020-09-18 22:27:07 +02:00
Franz Liedke
14b12f8ae8 Revert "Fix Post-actions being on top of Post Controls Dropdown"
This reverts commit fa29937d9e4dd868198bc2b003320c8431afed6f.
2020-09-18 22:27:07 +02:00
Franz Liedke
37d7068569 Revert "Run npm audit fix"
This reverts commit 791fd306e999066180cb67337566e17fa5e4bb1b.
2020-09-18 22:27:07 +02:00
Franz Liedke
6e1a9193ac Revert "infrastructure: Run npm audit fix"
This reverts commit ff1c52dcf84a4408fdc156a140f8718b401e7d7f.
2020-09-18 22:27:06 +02:00
Franz Liedke
ea6aea409d TS: Merge shims and tsconfig for now
This makes the structure less confusing, as long as the two parts are
not needed.

Once we actually have decided where to move the shared parts (to share
them with bundled and community extensions), and which parts to share,
this can easily be extracted again.
2020-09-18 22:27:06 +02:00
Franz Liedke
d572200cfb TS: Document shims files 2020-09-18 22:27:06 +02:00
Franz Liedke
e10ebf0489 TS config / shims: Fix indentation / whitespace 2020-09-18 22:27:05 +02:00
Franz Liedke
b251ff0469 Extend documentation 2020-09-18 22:27:05 +02:00
Franz Liedke
4a0947db4b TS: Be more specific in type assertion
See https://stackoverflow.com/a/33404548.
2020-09-18 22:27:05 +02:00
Alexander Skvortsov
32767d6321 Fix typescript errors 2020-09-18 22:27:04 +02:00
Alexander Skvortsov
27017b7181 Register jQuery with shims 2020-09-18 22:27:04 +02:00
Franz Liedke
bdd30ceecc Add documentation, keep lifecycle methods on top 2020-09-18 22:27:04 +02:00
Franz Liedke
abc98645a0 Fix typo, reduce diff 2020-09-18 22:27:03 +02:00
Franz Liedke
5d538adc33 Tweak Mithril documentation link 2020-09-18 22:27:03 +02:00
Franz Liedke
6f11567f91 Update comment
Who cares about Mithril 0.2? ;)
2020-09-18 22:27:03 +02:00
Alexander Skvortsov
537e2690e8 Replace get first element from filter with find 2020-09-18 22:27:03 +02:00
Franz Liedke
0e715a8c40 Restore comment
I love small diffs.
2020-09-18 22:27:02 +02:00
Franz Liedke
383a7e559f DiscussionListPane: Inject state, extract method 2020-09-18 22:27:02 +02:00
Franz Liedke
aeaa9a4b73 Reuse ComposerPostPreview in ReplyPlaceholder 2020-09-18 22:27:02 +02:00
Franz Liedke
85f8fc52b7 ComposerPostPreview: Add scroll anchoring callback
This will be needed to replace the `ReplyPlaceholderPreview` component.
2020-09-18 22:27:01 +02:00
Franz Liedke
174f3aba90 ComposerPostPreview: Allow injecting CSS class 2020-09-18 22:27:01 +02:00
Franz Liedke
47ce93d2fd Stop rendering previews when composer is hidden
This brings this component in line with the `ReplyPlaceholder` one.
2020-09-18 22:27:01 +02:00
Franz Liedke
24935eacaf ComposerPostPreview: Inject entire composer 2020-09-18 22:27:00 +02:00
Franz Liedke
0f99e7c015 Rename post preview component again
I want to reuse it for the reply placeholder as well, and that will
require injecting a composer state instance, so this naming seems to
make more sense.
2020-09-18 22:27:00 +02:00
Franz Liedke
691ae85e50 TextFormatterPreview: Add documentation 2020-09-18 22:27:00 +02:00
Franz Liedke
fb83f8c59c TextFormatterPreview: Pass in content callback
This makes the component itself less dependent on global state, which
has benefits for testability and reusability.
2020-09-18 22:26:59 +02:00
Franz Liedke
a6e6c54972 TextFormatterPreview: Expose in compat 2020-09-18 22:26:59 +02:00
Franz Liedke
6b5bdb5c41 Rename CommentPostPreview -> TextFormatterPreview
The idea is to be a bit more precise as to what this component does.
Also, there's already the `PostPreview` component which is actually a
link *to* a preview. Very confusing.
2020-09-18 22:26:59 +02:00
Alexander Skvortsov
d7ef260c54 Run code to rebuild tooltip oncreate AND onupdate 2020-09-18 22:26:58 +02:00
Alexander Skvortsov
571ed8d8e5 Don't store stuff on DOM elements. 2020-09-18 22:26:58 +02:00
Franz Liedke
42ad490096 mapRoutes: Fix outdated link to Mithril docs 2020-09-18 22:26:58 +02:00
Franz Liedke
09bead3ba2 Extend / correct documentation of SubtreeRetainer 2020-09-18 22:26:58 +02:00
Franz Liedke
abb896d430 Extend documentation of withAttr 2020-09-18 22:26:57 +02:00
Alexander Skvortsov
6e7c86ac50 Format 2020-09-18 22:26:57 +02:00
Alexander Skvortsov
2673dd2ee3 Don't show an error in console on promise rejection when not logged in and trying to reply / start a discussion 2020-09-18 22:26:57 +02:00
Alexander Skvortsov
cbd9c8dd4f Add a workaround for the blank SelectDropdown issue.
Workaround explained in the code.
2020-09-18 22:26:56 +02:00
Alexander Skvortsov
29d995de45 Fix NotificationList storing jquery objects 2020-09-18 22:26:42 +02:00
Franz Liedke
a5b2768836 Revert "Use some for needsRebuild in SubtreeRetainer"
This reverts commit 5318bd456856407dd92f44583bed5c88f0813a80.

My bad, I did not realize there was a side effect (storing stuff in
this.data) when suggesting this change. With that, using some() is much
less useful - plus it probably causes a bug because it does not always
iterate over all items once the callback returns true.
2020-09-18 22:26:42 +02:00
Alexander Skvortsov
398951f282 Fix SubtreeRetainer docblock 2020-09-18 22:26:42 +02:00
Alexander Skvortsov
07f04c7ba7 Use some for needsRebuild in SubtreeRetainer 2020-09-18 22:26:42 +02:00
Alexander Skvortsov
c8d5ca51bb Document AdminApplication location fix 2020-09-18 22:26:41 +02:00
Alexander Skvortsov
60dbd3f26c Significantly increase documentation for Component and Fragment 2020-09-18 22:26:41 +02:00
Alexander Skvortsov
e7f6e37799 Store $scrollParent and $notifications in the component state so we don't need to reobtain them 3 separate times 2020-09-18 22:26:41 +02:00
Alexander Skvortsov
27ffeb204e Add AffixedSidebar to compat 2020-09-18 22:26:40 +02:00
Alexander Skvortsov
884a5cf3b9 Run npm audit fix 2020-09-18 22:26:40 +02:00
Alexander Skvortsov
e895ca738d Move setRouteWithForcedRefresh util to common, add it to compat 2020-09-18 22:26:40 +02:00
Alexander Skvortsov
f41aec1043 Replace stray m.lazyRedraw and m.deferred on Model.js 2020-09-18 22:26:09 +02:00
Alexander Skvortsov
4c73d76668 Fix SuperTextarea instantiation: provide a DOM element, not a jquery collection 2020-09-18 22:26:09 +02:00
Alexander Skvortsov
f67194484b Fix going from one user page directly to another 2020-09-18 22:26:08 +02:00
Alexander Skvortsov
c8f47d519d Use the current route when when applying changeSort instead of always going to index 2020-09-18 22:26:08 +02:00
Alexander Skvortsov
e9b267a33a Call onRoute before refreshParams, so we can use app.current.get('routeName') in discussion params 2020-09-18 22:26:08 +02:00
Alexander Skvortsov
554c72c6db IndexPage: change routeName on app.current 2020-09-18 22:26:07 +02:00
Alexander Skvortsov
fb2b0a1d3e Replace leftover m.redraw(true) with m.redraw.sync() 2020-09-18 22:26:07 +02:00
Alexander Skvortsov
2ac18a39ed Make routeName available to pages again 2020-09-18 22:26:07 +02:00
Alexander Skvortsov
6ed3cb56d4 Fix ellipses button on comment post
The subtree retainer should take revealContent into account, so that when revealContent is toggled, it redraws the post
2020-09-18 22:26:06 +02:00
Alexander Skvortsov
038744f092 Introduce setRouteWithForcedRefresh util 2020-09-18 22:26:06 +02:00
Alexander Skvortsov
2edbd4508a Remove unnecessary vnode argument 2020-09-18 22:26:06 +02:00
Alexander Skvortsov
a6d4658dff Simplify TextEditor by moving oncreate from the textarea vnode in view to the component itself, and removing the redundant vnode lifecycle hooks (since those can be applied on the component as well) 2020-09-18 22:26:05 +02:00
Alexander Skvortsov
955c8121d3 Resolve import typo 2020-09-18 22:26:05 +02:00
Alexander Skvortsov
fd2dcd38d6 format 2020-09-18 22:26:05 +02:00
Alexander Skvortsov
520c7e7d0f Made texteditor actual textarea lifecycle methods extensible 2020-09-18 22:26:05 +02:00
Alexander Skvortsov
5ec9c52b04 formatting fix 2020-09-18 22:26:04 +02:00
Alexander Skvortsov
0341e64057 Make app.composer.show redraw synchronous, implement promises in reply, edit, and new discussion actions properly 2020-09-18 22:26:04 +02:00
Alexander Skvortsov
f1a480d3d7 Rename leftover props to attrs. ALthough this piece of code will probably be removed soon as per https://github.com/flarum/core/pull/2263 2020-09-18 22:25:42 +02:00
Alexander Skvortsov
aceac88013 Store bound handlers in this 2020-09-18 22:25:42 +02:00
Alexander Skvortsov
8cc9e18990 Fix undefined $scrollParent 2020-09-18 22:25:42 +02:00
Alexander Skvortsov
94d3bea53e Fix initAttrs being made static 2020-09-18 22:25:41 +02:00
Alexander Skvortsov
9e6cfcf05a Revert fix for opening modal from modal. Will be split to separate PR. 2020-09-18 22:25:41 +02:00
Alexander Skvortsov
b8abd2522e Add documentation for withAttr util 2020-09-18 22:25:41 +02:00
Alexander Skvortsov
376a00f24f make initAttrs static again 2020-09-18 22:25:40 +02:00
Alexander Skvortsov
43bfaa7400 Update SubtreeRetainer docblock 2020-09-18 22:25:40 +02:00
Alexander Skvortsov
7ba8a7122b Don't pass attrs to this.isProvided unnecessarily 2020-09-18 22:25:40 +02:00
Alexander Skvortsov
88bc291c86 Don't accept unnecessary vnode arguments in view 2020-09-18 22:25:39 +02:00
Alexander Skvortsov
e699ada1cc Don't pass attrs to Modal.content (we forgot to remove it, were experimenting with something else previously) 2020-09-18 22:25:39 +02:00
Alexander Skvortsov
2186584878 Fix m.route that should be m.route.set 2020-09-18 22:25:39 +02:00
Alexander Skvortsov
7beeae6269 typo fix 2020-09-18 22:25:38 +02:00
Alexander Skvortsov
e0e3d6ecae provide vnode to onupdate where it was actually needed 2020-09-18 22:25:38 +02:00
Alexander Skvortsov
f8bc58fd1a format 2020-09-18 22:25:38 +02:00
Alexander Skvortsov
764f50f469 Re-implement UsersSearchSource highlight logic without overriding vnode.children 2020-09-18 22:25:38 +02:00
Alexander Skvortsov
e8485db484 Revert removal of "controls" postfix in UserControls 2020-09-18 22:25:37 +02:00
Alexander Skvortsov
f017d7afbe Fix method that should have been oncreate initially being made as onupdate 2020-09-18 22:25:37 +02:00
Alexander Skvortsov
44c1e91f05 Call logic from onupdate in oncreate where relevant 2020-09-18 22:25:37 +02:00
Alexander Skvortsov
4b05e0073a Don't accept unnecessary vnode argument where it isn't used 2020-09-18 22:25:36 +02:00
Alexander Skvortsov
3764abee51 format 2020-09-18 22:25:36 +02:00
Alexander Skvortsov
015cedb29d Create new objects with spread when modifying vnode children instead of directly setting children when possible. 2020-09-18 22:25:36 +02:00
Alexander Skvortsov
32e9fa1f0b format 2020-09-18 22:25:35 +02:00
Alexander Skvortsov
2bdf0d7096 More consistent use of bidi (don't unecessarily define value 2020-09-18 22:25:35 +02:00
Alexander Skvortsov
b889fa1bbf format 2020-09-18 22:25:35 +02:00
Alexander Skvortsov
76c3494f9b Alert.js: move content definition back where it used to be 2020-09-18 22:25:35 +02:00
Alexander Skvortsov
6bcb76b914 Fix typo (and remove the whole unnecessary method) 2020-09-18 22:25:34 +02:00
Alexander Skvortsov
63984b43f9 Revert changes that have been separated out into https://github.com/flarum/core/pull/2262/files 2020-09-18 22:25:34 +02:00
Alexander Skvortsov
389bc59745 Formatting, docblock for Fragment 2020-09-18 22:25:18 +02:00
Alexander Skvortsov
2eb28ea396 Add fragment as parallel to component 2020-09-18 22:25:18 +02:00
Alexander Skvortsov
169b0fbd9b Fix modal backdrop not closing on app.modal.close() 2020-09-18 22:25:17 +02:00
Alexander Skvortsov
6b178b8204 Get rid of index filter route resolved (no longer necessary since filter route isn't a thing anymore) 2020-09-18 22:25:17 +02:00
Alexander Skvortsov
1d6e985107 Get rid of index.filter route 2020-09-18 22:24:27 +02:00
Alexander Skvortsov
f1fc0fecb7 Move title setting for index page to its own method so it can be used in reloads caused by route changes 2020-09-18 22:24:27 +02:00
David Sevilla Martin
94d8f7e726 remove unused attribute in DiscussionComposer input 2020-09-18 22:24:27 +02:00
David Sevilla Martin
b0891c42da create route resolver for /:filter (route overrides any others such as /tags, etc) 2020-09-18 22:24:26 +02:00
David Sevilla Martin
7d5bebb70a do not use custom route resolver solution for plain index page 2020-09-18 22:22:57 +02:00
David Sevilla Martin
b78db0268a update Component to error if 'tag' attribute is passed - messes with mithril 2020-09-18 22:22:56 +02:00
David Sevilla Martin
fcda092558 fix tsconfig.json 2020-09-18 22:22:56 +02:00
Alexander Skvortsov
f6dd87f72f Fix double-fade of posts in post stream by moving fadeIn to css 2020-09-18 22:22:56 +02:00
Alexander Skvortsov
d8d43d95e0 Remove unnecessary options=true, which breaks with patchMithril (used for a previous but now discarded patch) 2020-09-18 22:21:37 +02:00
David Sevilla Martin
51c7b17305 fix: add 'force' to UserPage sidebar buttons to refresh post/discussion lists 2020-09-18 22:21:37 +02:00
David Sevilla Martin
b592ceb199 update patchMithril to add 'force' attribute & not add endless browser history for same page 2020-09-18 22:21:36 +02:00
David Sevilla Martin
a083876b5f fix: DiscussionPage not updating when switching to a separate discussion (using pane) 2020-09-18 22:21:18 +02:00
David Sevilla Martin
b0cbe277c2 update IndexPage to clear discussions & refresh params using custom route solution 2020-09-18 22:21:18 +02:00
David Sevilla Martin
b1d948becc revert patchMithril changes for all links to re-call oninit 2020-09-18 22:21:18 +02:00
Alexander Skvortsov
a48568b17a Fix back button moving between posts in discussion 2020-09-18 22:21:17 +02:00
Alexander Skvortsov
e5cebd85ed update: admin/components/ExtensionsPage 2020-09-18 22:21:17 +02:00
Alexander Skvortsov
fbd5f6245b update: admin/components/AppearancePage 2020-09-18 22:21:17 +02:00
Alexander Skvortsov
3ce63bc035 update admin/components/SettingsModal 2020-09-18 22:21:16 +02:00
Alexander Skvortsov
bac5e7c94c update: admin/components/UploadImageButton 2020-09-18 22:21:16 +02:00
Alexander Skvortsov
97b0e61f61 update: admin/components/EditGroupModal 2020-09-18 22:21:16 +02:00
Alexander Skvortsov
604989be72 update: admin/components/PermissionGrid 2020-09-18 22:21:16 +02:00
Alexander Skvortsov
f9b1dfe499 update: admin/components/PermissionDropdown 2020-09-18 22:21:15 +02:00
Alexander Skvortsov
f611a44a08 update: admin/components/SettingDropdown 2020-09-18 22:21:15 +02:00
Alexander Skvortsov
911f1fd5c9 update: admin/components/MailPage 2020-09-18 22:21:15 +02:00
Alexander Skvortsov
0087b956ef update: admin/components/BasicsPage
- Not tested: locale selector, homepage selector, display name driver selector
2020-09-18 22:21:14 +02:00
Alexander Skvortsov
99b119f1fa update: admin/utils/saveSettings 2020-09-18 22:21:14 +02:00
Alexander Skvortsov
6be37fd376 update: admin/routes 2020-09-18 22:21:14 +02:00
Alexander Skvortsov
46741f63fe update: admin/components/AdminLinkButton 2020-09-18 22:21:13 +02:00
Alexander Skvortsov
cec00c0dd6 update: admin/components/AdminNav 2020-09-18 22:21:13 +02:00
Alexander Skvortsov
5f2c9da2f5 update: admin/components/SessionDropdown 2020-09-18 22:21:13 +02:00
Alexander Skvortsov
92de05e911 update: admin/components/HeaderSecondary 2020-09-18 22:21:12 +02:00
Alexander Skvortsov
68aa6e26da update: admin/AdminApplication 2020-09-18 22:21:12 +02:00
Alexander Skvortsov
8b7fa012c7 Remove workaround for m.route.Link vnode.text issue, as a more global patch has been added to patchMithril 2020-09-18 22:21:12 +02:00
Alexander Skvortsov
61231debd3 fix forum/components/DiscussionPage: page title not being updated when opening a discussion 2020-09-18 22:21:12 +02:00
Alexander Skvortsov
e771ec90c4 Remove patch for double oninit on DiscussionPage: we need to find and fix the underlying issue, or it will affect ALL links to posts/discussions 2020-09-18 22:21:11 +02:00
Alexander Skvortsov
ab85b49845 fix patchMithril: add Fix for m.route.Link with vnode.children not showing anything. 2020-09-18 22:21:11 +02:00
Alexander Skvortsov
04e5d5884f Patch DiscussionListItem to avoid double re-oninit when going to a discussion page 2020-09-18 22:21:11 +02:00
Alexander Skvortsov
ddc1141106 Fix empty error alerts. We might want to bring back the old order for alerts.show (children after attrs) 2020-09-18 22:21:10 +02:00
Alexander Skvortsov
a48cc19814 Remove unused config method from SessionDropdown 2020-09-18 22:21:10 +02:00
Alexander Skvortsov
6d18b700ec Rename config to oncreate for NotificationGrid 2020-09-18 22:21:10 +02:00
Alexander Skvortsov
b2bc427b3f fix: forum/components/ForgotPasswordModal (fix alert content) 2020-09-18 22:21:09 +02:00
Alexander Skvortsov
1f94ffc842 cleanup: common/components/Navigation (remove unused config 2020-09-18 22:21:09 +02:00
Alexander Skvortsov
3d91268493 Fix showing alert on user deletion (UserControls) 2020-09-18 22:21:09 +02:00
Alexander Skvortsov
81fd986881 forum/components/Notification: Support external links 2020-09-18 22:21:09 +02:00
Alexander Skvortsov
d1b0030292 update: forum/utils/alertEmailConfirmation
- ResendButton has been expanded into a full(er) component to avoid storing component instances, ContainedAlert has been removed
2020-09-18 22:21:08 +02:00
Alexander Skvortsov
2c395a781c update: forum/components/NotificationsPage 2020-09-18 22:21:08 +02:00
Alexander Skvortsov
21861f231b Remove deprecated "BC Layers", since BC is broken anyway 2020-09-18 22:21:08 +02:00
Alexander Skvortsov
537f5e833e update: common/components/Select 2020-09-18 22:21:07 +02:00
Alexander Skvortsov
4d45aaa9ae update: forum/components/LoginButton 2020-09-18 22:21:07 +02:00
Alexander Skvortsov
529f8e5f32 SubtreeRetainer: change docblock "this.props" to "this.attrs" 2020-09-18 22:21:07 +02:00
Alexander Skvortsov
991d90bf4a LoginModal: change references from props to attrs 2020-09-18 22:21:06 +02:00
Alexander Skvortsov
38fed603f8 Remove deprecated preferenceSaver from settingspage 2020-09-18 22:21:06 +02:00
Alexander Skvortsov
9691a6ab92 SignupModal: rename props to attrs 2020-09-18 22:21:06 +02:00
Alexander Skvortsov
e8e4b64d7d mapRoutes: remove obsolete setting of routeName attr on components 2020-09-18 22:21:06 +02:00
Alexander Skvortsov
35d76515d3 update: forum/components/TextEditorButton 2020-09-18 22:21:05 +02:00
Alexander Skvortsov
5d34124a02 replace config and href with route on Notification and NotificationList 2020-09-18 22:21:05 +02:00
Alexander Skvortsov
16a6f82e8f replace href and config with route on PostPreview (which is unused, also, update the component 2020-09-18 22:21:05 +02:00
Alexander Skvortsov
8a0c241a8e remove affixSidebar from compat.js 2020-09-18 22:21:04 +02:00
Alexander Skvortsov
a376c0e596 Rename UserPageSidebar to AffixedSidebar, make it a much more reusable component 2020-09-18 22:21:04 +02:00
Alexander Skvortsov
5ccf9d420e Remove affixSidebar util (now contained in UserPageSidebar component 2020-09-18 22:21:04 +02:00
Alexander Skvortsov
742f89f660 add: forum/components/UserPageSidebar
- extracted from UserPage, replaces affixSidebar util
2020-09-18 22:21:03 +02:00
Alexander Skvortsov
916cf4b546 fix: Composer height handle 2020-09-18 22:21:03 +02:00
Alexander Skvortsov
f127e67fd4 Update: EventPost, DiscussionRenamedPost 2020-09-18 22:21:03 +02:00
Alexander Skvortsov
3e79c3e3ff update: forum/components/EditUserModal 2020-09-18 22:21:02 +02:00
Alexander Skvortsov
0172dfd79c update: forum/components/ChangeEmailModal 2020-09-18 22:21:02 +02:00
Alexander Skvortsov
eb627544fa update:forum/components/ChangePasswordModal 2020-09-18 22:21:02 +02:00
Alexander Skvortsov
73c0a90da7 update: forum/components/RenameDiscussionModal 2020-09-18 22:21:01 +02:00
Alexander Skvortsov
ca0f8f2d72 Fix preview functionality of ReplyComposer and EditPostComposer
- options= true means that the init method of DiscussionPage will not be recalled (as per patchMithril
- DiscussionPage now caches the previous route, and compares it to the current one onupdate. If the route has change. We update this cached prevRoute in positionChanged to avoid unneessary goToNumber calls.
2020-09-18 22:21:01 +02:00
Alexander Skvortsov
090df13e7f fix: ComposerState (use m.redraw.sync() instead of m.redraw(true)) 2020-09-18 22:21:01 +02:00
Alexander Skvortsov
d1a1277f88 update: forum/components/EditPostComponent 2020-09-18 22:21:01 +02:00
Alexander Skvortsov
31b2ab1b2b update: forum/components/ReplyComposer 2020-09-18 22:21:00 +02:00
Alexander Skvortsov
2590073a50 fix: forum/components/TextEditor 2020-09-18 22:21:00 +02:00
Alexander Skvortsov
4402dc81ac wip fix: forum/components/DiscussionPage
- rename onunload to onremove. We are still unable to cancel the unload though.
2020-09-18 22:21:00 +02:00
Alexander Skvortsov
f93a255a2f fix: ConfirmDocumentUnload 2020-09-18 22:20:59 +02:00
Alexander Skvortsov
ddb0a9f1ce update/fix: forum/components/SplitDropdown 2020-09-18 22:20:59 +02:00
Alexander Skvortsov
f44caf1600 update: forum/components/PostStreamScrubber 2020-09-18 22:20:59 +02:00
Alexander Skvortsov
89b6847710 update: forum/components/PostStream 2020-09-18 22:20:58 +02:00
Alexander Skvortsov
2fb885175a update: forum/components/TextEditor 2020-09-18 22:20:04 +02:00
Alexander Skvortsov
7d9db2f4ae update: forum/components/CommentPost
- removed now irrelevant Mithril 0.1 workaround for ul jsx
- Move CommentPostPreview into its own class
2020-09-18 22:20:04 +02:00
Alexander Skvortsov
5d073941c9 formatting fix 2020-09-18 22:20:03 +02:00
Alexander Skvortsov
f664fa5be7 update: forum/utils/PostControls 2020-09-18 22:20:03 +02:00
Alexander Skvortsov
aa4b58d7aa update: forum/components/PostMeta 2020-09-18 22:20:03 +02:00
Alexander Skvortsov
3120eb6f63 update: forum/components/PostEdited 2020-09-18 22:20:02 +02:00
Alexander Skvortsov
aac54a1d28 update: forum/components/DiscussionHero 2020-09-18 22:20:02 +02:00
Alexander Skvortsov
0d0841d019 formatting fix 2020-09-18 22:20:02 +02:00
Alexander Skvortsov
83d2dbd290 Fix: extend Component in DiscussionListPane 2020-09-18 22:20:02 +02:00
Alexander Skvortsov
7e5b40c532 Fix: extend Component in ReplyPlaceholderComponent 2020-09-18 22:20:01 +02:00
Alexander Skvortsov
6c9971eeba add: forum/components/CommentPostPreview
- This has been extracted from CommentPost
2020-09-18 22:20:01 +02:00
Alexander Skvortsov
37a690833a update: forum/components/Post
- attrs has been renamed to elementAttrs
- As subtree retention is now implemented via onbeforeupdate, view no longer needs to return a retain vnode, and as such, has been significantly simplified
2020-09-18 22:20:01 +02:00
Alexander Skvortsov
fa2301b5c1 update: forum/components/DiscussionPage
- The discussion list pane has been extracted to the DiscussionListPane component
2020-09-18 22:20:00 +02:00
Alexander Skvortsov
edca7b93ec add forum/components/DiscussionListPane
- Extract this from DiscussionPage
2020-09-18 22:20:00 +02:00
Alexander Skvortsov
095dce9a3e Mount AlertManager in Application 2020-09-18 22:20:00 +02:00
Alexander Skvortsov
479f655bb3 update: common/components/AlertManager 2020-09-18 22:19:59 +02:00
Alexander Skvortsov
41d6e91318 update: common/states/AlertManagerState
- Children is now the first argument of the alerts.show function, since it's no longer managed through attrs. It's also more relvant than attrs, which is why I put it first, but we can definitely reverse the two.
2020-09-18 22:19:59 +02:00
Alexander Skvortsov
87414995b6 fix: forum/components/UserSearchSource
- Use route attr instead of href and config
2020-09-18 22:19:59 +02:00
Alexander Skvortsov
2c93b5f801 update: forum/components/PostUser 2020-09-18 22:19:59 +02:00
Alexander Skvortsov
7498f5e506 fix: forum/components/UserCard
- Use route attr instead of href and config
2020-09-18 22:19:58 +02:00
Alexander Skvortsov
79f5291f04 update: forum/states/PostStreamState
- Change m.deferred to native promise
2020-09-18 22:19:58 +02:00
Alexander Skvortsov
ed3b923f58 update: forum/components/ReplyPlaceholder
- The preview has been extracted to ReplyPlaceholderPreview
2020-09-18 22:19:58 +02:00
Alexander Skvortsov
1ce06611ce add: Extract ReplyPlaceholderPreview from ReplyPlaceholder 2020-09-18 22:19:57 +02:00
Alexander Skvortsov
27bacd779b Update patchMithril modified link documentation with a better link to mithril's docs. 2020-09-18 22:19:57 +02:00
Alexander Skvortsov
02acacfdcb Merge branch 'mithril-2-update' of github.com:flarum/core into mithril-2-update 2020-09-18 22:19:57 +02:00
Alexander Skvortsov
23d95a7566 Add Pane to ForumApplication 2020-09-18 22:19:56 +02:00
Alexander Skvortsov
43164df79e formatting fix 2020-09-18 22:19:56 +02:00
Alexander Skvortsov
46e704b27b update: IndexPage (move onunload contents into onremove) 2020-09-18 22:19:56 +02:00
David Sevilla Martin
e55867acb4 format 2020-09-18 22:19:55 +02:00
Alexander Skvortsov
3596425bde update: History (re-init component even if already on index page) 2020-09-18 22:19:55 +02:00
David Sevilla Martin
b43452223f fix: Button not appearing disabled when loading 2020-09-18 22:19:55 +02:00
Alexander Skvortsov
70697be8c0 infrastructure: (mostly) force re-calling oninit when a route change is handled by the same component
- Due to mithril 2.0, setting a route will not re-call oninit if the component handling the route has not changed.
- Mithril allows us to provide an options parameter to m.route.set, which, if has the state.key parameter changed, will force a re-oninit. However, manually implementing this on every button and component is both tedious, and will make further changes in functionality difficult
- To that end, we can add in this patch here, which will take care of most cases. Code that explicitly calls m.route.set will still need to include this options parameter.
2020-09-18 22:19:55 +02:00
Alexander Skvortsov
c20ae678f5 Move config method of IndexPage into oncreate and onremove 2020-09-18 22:19:54 +02:00
Alexander Skvortsov
30a61b8b42 fix: forum/components/DiscussionListItem (use route instead of config) 2020-09-18 22:19:54 +02:00
Alexander Skvortsov
824fe95346 update: DisussionSearchSource (use route attr to support linking without refreshing) 2020-09-18 22:19:54 +02:00
David Sevilla Martin
8475d176e0 fix: forum/routes /:filter handling /settings 2020-09-18 22:19:53 +02:00
David Sevilla Martin
95f4dc771d update: forum/components/SettingsPage 2020-09-18 22:19:53 +02:00
David Sevilla Martin
68caf45f33 update: forum/components/NotificationGrid 2020-09-18 22:19:53 +02:00
David Sevilla Martin
f72d118bec cleanup: common/components/Button 2020-09-18 22:19:52 +02:00
David Sevilla Martin
652d961907 update: common/components/FieldSet 2020-09-18 22:19:52 +02:00
David Sevilla Martin
71178245fc update: common/components/Switch 2020-09-18 22:19:52 +02:00
David Sevilla Martin
cfd1f01299 update: common/components/Checkbox 2020-09-18 22:19:52 +02:00
David Sevilla Martin
674f55e91d fix: support text-only vnodes with extractText
Also seems to fix not having a space between 'Posts' and the number of posts in the UserPage tooltips
2020-09-18 22:19:51 +02:00
David Sevilla Martin
f897b58f29 add: common/utils/withAttr
Replaces m.withAttr
2020-09-18 22:19:51 +02:00
David Sevilla Martin
5c49b71c02 update: forum/utils/UserControls 2020-09-18 22:19:51 +02:00
David Sevilla Martin
b9ba5b63f1 update: forum/components/DiscussionsUserPage 2020-09-18 22:19:50 +02:00
David Sevilla Martin
6e88dfb2cb update: forum/components/PostsUserPage 2020-09-18 22:19:50 +02:00
David Sevilla Martin
c899e11070 update: forum/components/UserPage 2020-09-18 22:19:50 +02:00
David Sevilla Martin
784b5cc03c update: forum/components/AvatarEditor 2020-09-18 22:19:49 +02:00
David Sevilla Martin
c17d7cd23f fix: Application#updateTitle using m.route() instead of m.route.get() 2020-09-18 22:19:49 +02:00
David Sevilla Martin
4af34265cc update: common/components/GroupBadge 2020-09-18 22:19:49 +02:00
Alexander Skvortsov
1616d8f1c7 Fix patchMithril for route 2020-09-18 22:19:48 +02:00
Alexander Skvortsov
57b85f501a Add composer mount to ForumApplication 2020-09-18 22:19:48 +02:00
Alexander Skvortsov
91609b8a71 update: forum/components/DiscussionComposer 2020-09-18 22:19:48 +02:00
Alexander Skvortsov
9d8b466d57 update: forum/states/ComposerState 2020-09-18 22:19:48 +02:00
Alexander Skvortsov
02154d05e5 update: forum/components/TextEditor 2020-09-18 22:19:47 +02:00
Alexander Skvortsov
a454b185e9 update forum/components/ComposerButton 2020-09-18 22:19:47 +02:00
Alexander Skvortsov
84c5248872 update: forum/components/ComposerBody 2020-09-18 22:19:47 +02:00
Alexander Skvortsov
2a784009fb update: Composer 2020-09-18 22:19:46 +02:00
Alexander Skvortsov
070865f825 update: IndexPage to fix newDiscussionAction (use promise instead of deferred) 2020-09-18 22:19:46 +02:00
Alexander Skvortsov
9615fd3e39 update: common/components/ConfirmDocumentUnload 2020-09-18 22:19:46 +02:00
Alexander Skvortsov
dde9c9c51b Mount navigation in ForumApplication 2020-09-18 22:19:45 +02:00
Alexander Skvortsov
6547290472 update: common/components/Navigation 2020-09-18 22:19:45 +02:00
Alexander Skvortsov
74f6a3e6ce Set empty object as default attrs for all components 2020-09-18 22:19:45 +02:00
Alexander Skvortsov
98740472a8 update: common/Model (use body instead of data on m.request) 2020-09-18 22:19:45 +02:00
Alexander Skvortsov
1e9825de4f update: forum/components/WelcomeHero 2020-09-18 22:19:44 +02:00
Alexander Skvortsov
d14ca79dfa update: listItems (filter out null/falsy values) 2020-09-18 22:19:44 +02:00
Alexander Skvortsov
5cd5f27769 update: forum/components/HeaderSecondary (add search, finishing the header) 2020-09-18 22:19:44 +02:00
Alexander Skvortsov
d05b63eddd update: forum/states/GlobalSearchState 2020-09-18 22:19:43 +02:00
Alexander Skvortsov
48c7354c08 update: forum/components/Search 2020-09-18 22:19:43 +02:00
Alexander Skvortsov
0fe635d32c update: common/components/Badge 2020-09-18 22:19:43 +02:00
Alexander Skvortsov
2c2a42030a update: UsersSearchSource
- mithril 2 prefers to store single text children in vnode.text, so we need to move it back to children to give us a consistent API
2020-09-18 22:19:42 +02:00
Alexander Skvortsov
37d2902cd1 Fix m.route => m.route.set in NotificationsDropdown 2020-09-18 22:19:42 +02:00
Alexander Skvortsov
66c2f6b76a update: forum/components/DiscussionSearchSource 2020-09-18 22:19:42 +02:00
Alexander Skvortsov
4d68636544 update: forum/components/SessionDropdown 2020-09-18 22:19:41 +02:00
Alexander Skvortsov
73891b751b update: forum/components/Notification, forum/components/DiscussionRenamedNotification
- Not yet tested
2020-09-18 22:19:41 +02:00
Alexander Skvortsov
fe011bf285 Add notifications to HeaderSecondary 2020-09-18 22:19:41 +02:00
Alexander Skvortsov
524dde31d4 update: forum/components/NotificationList 2020-09-18 22:19:41 +02:00
Alexander Skvortsov
b69fc01ab3 Uncomment notifications in ForumApplication 2020-09-18 22:19:40 +02:00
Alexander Skvortsov
20e69e6351 update: NotificationsDropdown 2020-09-18 22:19:40 +02:00
Alexander Skvortsov
d097e7ed4b Remove stray console.log 2020-09-18 22:19:40 +02:00
Alexander Skvortsov
63e07b2044 update: forum/utils/DiscussionControls
- replyAction has not yet been tested.
2020-09-18 22:19:39 +02:00
Alexander Skvortsov
60c3f23667 update: forum/components/DiscussionListItem 2020-09-18 22:19:39 +02:00
Alexander Skvortsov
9793c10610 infrastructure: For SubtreeRetainer, return a boolean, not a vnode, as subtree retaining is now managed by onbeforeupdate 2020-09-18 22:18:32 +02:00
Alexander Skvortsov
29af3e6d8b infrastructure: use params instead of data for get requests in common/Store 2020-09-18 22:18:32 +02:00
Alexander Skvortsov
703c3442da update: forum/components/UserCard 2020-09-18 22:18:32 +02:00
Alexander Skvortsov
e0d3a8c733 update: common/helpers/listItems: fix isSeparator 2020-09-18 22:18:32 +02:00
Alexander Skvortsov
527748ff66 update: forum/components/TerminalPost 2020-09-18 22:18:31 +02:00
Alexander Skvortsov
eb440bb9b6 update: forum/components/DiscussionList 2020-09-18 22:18:31 +02:00
Matthew Kilgore
d71f77d592 update: common/components/Placeholder.js 2020-09-18 22:18:31 +02:00
Matthew Kilgore
8b891abb2b update: forum/components/IndexPage.js 2020-09-18 22:18:30 +02:00
Matthew Kilgore
5f0dcc71ba update: common/components.Page.js 2020-09-18 22:18:30 +02:00
Matthew Kilgore
4fcafe3b2f Set application drawer 2020-09-18 22:18:30 +02:00
Matthew Kilgore
29065e1ee9 infrastructure: provide component classes instead of instances to routes.js 2020-09-18 22:18:29 +02:00
Matthew Kilgore
2b39bd7a0d Fix classList usage in listItems 2020-09-18 22:18:29 +02:00
Matthew Kilgore
94fe3236d7 infrastructure: Add routing to common/Application 2020-09-18 22:18:29 +02:00
Matthew Kilgore
3b6e5a0caf infrastructure: Add routes, history, search to ForumApplication 2020-09-18 22:18:29 +02:00
Matthew Kilgore
9ac8f1543a update: forum/utils/History.js 2020-09-18 22:18:28 +02:00
Matthew Kilgore
439e3a5a9a update: common/components/LinkButton 2020-09-18 22:18:28 +02:00
Matthew Kilgore
883e1a9d6a infrastruture: include the class name of the component where children is provided 2020-09-18 22:18:28 +02:00
Matthew Kilgore
2ac2edbbad Replace mithril-stream with mithril/stream, set it as a global via m.stream 2020-09-18 22:18:27 +02:00
Matthew Kilgore
18148141c3 update: common/components/RequestErrorModal 2020-09-18 22:18:27 +02:00
Matthew Kilgore
10f4223028 Add error warning if children attr is ever used 2020-09-18 22:18:27 +02:00
Matthew Kilgore
e10220ae47 update: Fix Alert on modals 2020-09-18 22:18:26 +02:00
Matthew Kilgore
dcd14821c2 infrastructure: revert to using this.attrs 2020-09-18 22:18:26 +02:00
Alexander Skvortsov
edeaa5855c update: SignUpModal 2020-09-18 22:18:26 +02:00
Alexander Skvortsov
cc91244f1a update: LoginModal 2020-09-18 22:18:25 +02:00
Alexander Skvortsov
5faab8bdbd update: ForgotPasswordModal 2020-09-18 22:18:25 +02:00
Alexander Skvortsov
99d79e7571 update: Modal 2020-09-18 22:18:25 +02:00
Alexander Skvortsov
5606eae0f1 update: ModalManager 2020-09-18 22:18:25 +02:00
Alexander Skvortsov
62cb71d4e1 update: ModalManagerState 2020-09-18 22:18:24 +02:00
Alexander Skvortsov
e28ba4acff infrastructure: fix broken translation of strings with html tags (vnodes) passed into them
Under the old system, we would set the "childen" property of the vnode in the translation to an array containing the text between the tag as a raw string. Mithril 2 expects everything to be a vnode, and as such, errors when processing this raw string. This commit runs that "children" array of raw strings (and possibly other vnodes) through m.fragment to convert everything to vnodes.
2020-09-18 22:18:24 +02:00
Alexander Skvortsov
5b3914535d infrastructure: Replace m.deferred in Application with native promise functionality 2020-09-18 22:18:24 +02:00
Alexander Skvortsov
44975bc606 update: common/Session.js
- Use body instead of data
- Use template literal instead of concatenation
2020-09-18 22:18:23 +02:00
Alexander Skvortsov
f7931e8a30 infrastructure: uncomment modal manager mount in application mount 2020-09-18 22:18:23 +02:00
Alexander Skvortsov
3ba655b2f9 infrastructure: install mithril streams package 2020-09-18 22:18:23 +02:00
Alexander Skvortsov
735ecab446 infrastructure: Run npm audit fix 2020-09-18 22:18:22 +02:00
Matthew Kilgore
64d4eb8c4c update: forum/components/HeaderSecondary (no search, session, notifs) 2020-09-18 22:18:22 +02:00
Matthew Kilgore
e4f1a397d6 update: forum/components/HeaderPrimary 2020-09-18 22:18:22 +02:00
Matthew Kilgore
5a244dcfd2 update: common/components/SelectDropdown 2020-09-18 22:18:21 +02:00
Matthew Kilgore
b60309284c update: common/components/Dropdown 2020-09-18 22:18:21 +02:00
Matthew Kilgore
392fe98c02 comment routes and components and states in forum application 2020-09-18 22:18:21 +02:00
Matthew Kilgore
873f489fec update: common/helpers/listItems 2020-09-18 22:18:21 +02:00
Matthew Kilgore
9d8374208f update: common/components/Button 2020-09-18 22:18:20 +02:00
Matthew Kilgore
07551b4890 update: common/components/Button 2020-09-18 22:18:20 +02:00
Matthew Kilgore
b2c147c147 update: common/components/LoadingIndicator 2020-09-18 22:18:20 +02:00
Matthew Kilgore
5bf5bd36ee update: common/utils/patchMithril (remove unnecessary hacks)
Removed m.prop AND m.withAttr, no alternatives yet (m.prop is mithril/stream import)
2020-09-18 22:18:19 +02:00
Matthew Kilgore
4435ff193a update: common/Component (& converted a tad) 2020-09-18 22:18:19 +02:00
Matthew Kilgore
5c30f8fa67 Setup Mithril 2 2020-09-18 22:18:19 +02:00
128 changed files with 2397 additions and 2064 deletions

11
js/package-lock.json generated
View File

@@ -1075,6 +1075,11 @@
"integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==",
"dev": true
},
"@types/mithril": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/mithril/-/mithril-2.0.3.tgz",
"integrity": "sha512-cZHOdO2IiXYeyjeDYdbOisSdfaJRzfmRo3zVzgu33IWTMA0KEQObp9fdvqcuYdPz93iJ1yCl19GcEjo/9yv+yA=="
},
"@types/parse-json": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@@ -3807,9 +3812,9 @@
}
},
"mithril": {
"version": "0.2.8",
"resolved": "https://registry.npmjs.org/mithril/-/mithril-0.2.8.tgz",
"integrity": "sha512-9XuGnVmS2OyFexUuP/CcJFFJjHLM+RGYBxyVRNyQ6khbMfDJIF/xyZ4zq18ZRfPagpFmWUFpjHd5ZqPULGZyNg=="
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/mithril/-/mithril-2.0.4.tgz",
"integrity": "sha512-mgw+DMZlhMS4PpprF6dl7ZoeZq5GGcAuWnrg5e12MvaGauc4jzWsDZtVGRCktsiQczOEUr2K5teKbE5k44RlOg=="
},
"mixin-deep": {
"version": "1.3.2",

View File

@@ -3,6 +3,7 @@
"name": "@flarum/core",
"dependencies": {
"@babel/preset-typescript": "^7.10.1",
"@types/mithril": "^2.0.3",
"bootstrap": "^3.4.1",
"classnames": "^2.2.5",
"color-thief-browser": "^2.0.2",
@@ -13,7 +14,7 @@
"jquery.hotkeys": "^0.1.0",
"lodash-es": "^4.17.14",
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",
"mithril": "^0.2.8",
"mithril": "^2.0.4",
"punycode": "^2.1.1",
"spin.js": "^3.1.0",
"webpack": "^4.43.0",

48
js/shims.d.ts vendored Normal file
View File

@@ -0,0 +1,48 @@
// Mithril
import * as Mithril from 'mithril';
import Stream from 'mithril/stream';
// Other third-party libs
import * as _dayjs from 'dayjs';
import * as _$ from 'jquery';
// Globals from flarum/core
import Application from './src/common/Application';
/**
* Helpers that flarum/core patches into Mithril
*/
interface m extends Mithril.Static {
prop: typeof Stream;
}
/**
* Export Mithril typings globally.
*
* This lets us use these typings without an extra import everywhere we use
* Mithril in a TypeScript file.
*/
export as namespace Mithril;
/**
* flarum/core exposes several extensions globally:
*
* - jQuery for convenient DOM manipulation
* - Mithril for VDOM and components
* - dayjs for date/time operations
*
* Since these are already part of the global namespace, extensions won't need
* to (and should not) bundle these themselves.
*/
declare global {
const $: typeof _$;
const m: m;
const dayjs: typeof _dayjs;
}
/**
* All global variables owned by flarum/core.
*/
declare global {
const app: Application;
}

View File

@@ -27,13 +27,18 @@ export default class AdminApplication extends Application {
* @inheritdoc
*/
mount() {
m.mount(document.getElementById('app-navigation'), Navigation.component({ className: 'App-backControl', drawer: true }));
m.mount(document.getElementById('header-navigation'), Navigation.component());
m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component());
m.mount(document.getElementById('admin-navigation'), AdminNav.component());
m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
m.mount(document.getElementById('header-navigation'), Navigation);
m.mount(document.getElementById('header-primary'), HeaderPrimary);
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
m.mount(document.getElementById('admin-navigation'), AdminNav);
// Mithril does not render the home route on https://example.com/admin, so
// we need to go to https://example.com/admin#/ explicitly.
if (!document.location.hash) document.location.hash = '#/';
m.route.prefix = '#';
m.route.mode = 'hash';
super.mount();
// If an extension has just been enabled, then we will run its settings

View File

@@ -10,11 +10,7 @@
import LinkButton from '../../common/components/LinkButton';
export default class AdminLinkButton extends LinkButton {
getButtonContent() {
const content = super.getButtonContent();
content.push(<div className="AdminLinkButton-description">{this.props.description}</div>);
return content;
getButtonContent(children) {
return [...super.getButtonContent(children), <div className="AdminLinkButton-description">{this.attrs.description}</div>];
}
}

View File

@@ -31,62 +31,74 @@ export default class AdminNav extends Component {
items.add(
'dashboard',
AdminLinkButton.component({
href: app.route('dashboard'),
icon: 'far fa-chart-bar',
children: app.translator.trans('core.admin.nav.dashboard_button'),
description: app.translator.trans('core.admin.nav.dashboard_text'),
})
AdminLinkButton.component(
{
href: app.route('dashboard'),
icon: 'far fa-chart-bar',
description: app.translator.trans('core.admin.nav.dashboard_text'),
},
app.translator.trans('core.admin.nav.dashboard_button')
)
);
items.add(
'basics',
AdminLinkButton.component({
href: app.route('basics'),
icon: 'fas fa-pencil-alt',
children: app.translator.trans('core.admin.nav.basics_button'),
description: app.translator.trans('core.admin.nav.basics_text'),
})
AdminLinkButton.component(
{
href: app.route('basics'),
icon: 'fas fa-pencil-alt',
description: app.translator.trans('core.admin.nav.basics_text'),
},
app.translator.trans('core.admin.nav.basics_button')
)
);
items.add(
'mail',
AdminLinkButton.component({
href: app.route('mail'),
icon: 'fas fa-envelope',
children: app.translator.trans('core.admin.nav.email_button'),
description: app.translator.trans('core.admin.nav.email_text'),
})
AdminLinkButton.component(
{
href: app.route('mail'),
icon: 'fas fa-envelope',
description: app.translator.trans('core.admin.nav.email_text'),
},
app.translator.trans('core.admin.nav.email_button')
)
);
items.add(
'permissions',
AdminLinkButton.component({
href: app.route('permissions'),
icon: 'fas fa-key',
children: app.translator.trans('core.admin.nav.permissions_button'),
description: app.translator.trans('core.admin.nav.permissions_text'),
})
AdminLinkButton.component(
{
href: app.route('permissions'),
icon: 'fas fa-key',
description: app.translator.trans('core.admin.nav.permissions_text'),
},
app.translator.trans('core.admin.nav.permissions_button')
)
);
items.add(
'appearance',
AdminLinkButton.component({
href: app.route('appearance'),
icon: 'fas fa-paint-brush',
children: app.translator.trans('core.admin.nav.appearance_button'),
description: app.translator.trans('core.admin.nav.appearance_text'),
})
AdminLinkButton.component(
{
href: app.route('appearance'),
icon: 'fas fa-paint-brush',
description: app.translator.trans('core.admin.nav.appearance_text'),
},
app.translator.trans('core.admin.nav.appearance_button')
)
);
items.add(
'extensions',
AdminLinkButton.component({
href: app.route('extensions'),
icon: 'fas fa-puzzle-piece',
children: app.translator.trans('core.admin.nav.extensions_button'),
description: app.translator.trans('core.admin.nav.extensions_text'),
})
AdminLinkButton.component(
{
href: app.route('extensions'),
icon: 'fas fa-puzzle-piece',
description: app.translator.trans('core.admin.nav.extensions_text'),
},
app.translator.trans('core.admin.nav.extensions_button')
)
);
return items;

View File

@@ -6,15 +6,16 @@ import EditCustomHeaderModal from './EditCustomHeaderModal';
import EditCustomFooterModal from './EditCustomFooterModal';
import UploadImageButton from './UploadImageButton';
import saveSettings from '../utils/saveSettings';
import withAttr from '../../common/utils/withAttr';
export default class AppearancePage extends Page {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
this.primaryColor = m.prop(app.data.settings.theme_primary_color);
this.secondaryColor = m.prop(app.data.settings.theme_secondary_color);
this.darkMode = m.prop(app.data.settings.theme_dark_mode);
this.coloredHeader = m.prop(app.data.settings.theme_colored_header);
this.primaryColor = m.stream(app.data.settings.theme_primary_color);
this.secondaryColor = m.stream(app.data.settings.theme_secondary_color);
this.darkMode = m.stream(app.data.settings.theme_dark_mode);
this.coloredHeader = m.stream(app.data.settings.theme_colored_header);
}
view() {
@@ -27,40 +28,34 @@ export default class AppearancePage extends Page {
<div className="helpText">{app.translator.trans('core.admin.appearance.colors_text')}</div>
<div className="AppearancePage-colors-input">
<input
className="FormControl"
type="text"
placeholder="#aaaaaa"
value={this.primaryColor()}
onchange={m.withAttr('value', this.primaryColor)}
/>
<input
className="FormControl"
type="text"
placeholder="#aaaaaa"
value={this.secondaryColor()}
onchange={m.withAttr('value', this.secondaryColor)}
/>
<input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.primaryColor} />
<input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.secondaryColor} />
</div>
{Switch.component({
state: this.darkMode(),
children: app.translator.trans('core.admin.appearance.dark_mode_label'),
onchange: this.darkMode,
})}
{Switch.component(
{
state: this.darkMode(),
onchange: this.darkMode,
},
app.translator.trans('core.admin.appearance.dark_mode_label')
)}
{Switch.component({
state: this.coloredHeader(),
children: app.translator.trans('core.admin.appearance.colored_header_label'),
onchange: this.coloredHeader,
})}
{Switch.component(
{
state: this.coloredHeader(),
onchange: this.coloredHeader,
},
app.translator.trans('core.admin.appearance.colored_header_label')
)}
{Button.component({
className: 'Button Button--primary',
type: 'submit',
children: app.translator.trans('core.admin.appearance.submit_button'),
loading: this.loading,
})}
{Button.component(
{
className: 'Button Button--primary',
type: 'submit',
loading: this.loading,
},
app.translator.trans('core.admin.appearance.submit_button')
)}
</fieldset>
</form>
@@ -79,31 +74,37 @@ export default class AppearancePage extends Page {
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_header_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_header_text')}</div>
{Button.component({
className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_header_button'),
onclick: () => app.modal.show(EditCustomHeaderModal),
})}
{Button.component(
{
className: 'Button',
onclick: () => app.modal.show(EditCustomHeaderModal),
},
app.translator.trans('core.admin.appearance.edit_header_button')
)}
</fieldset>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_footer_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_footer_text')}</div>
{Button.component({
className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_footer_button'),
onclick: () => app.modal.show(EditCustomFooterModal),
})}
{Button.component(
{
className: 'Button',
onclick: () => app.modal.show(EditCustomFooterModal),
},
app.translator.trans('core.admin.appearance.edit_footer_button')
)}
</fieldset>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_styles_text')}</div>
{Button.component({
className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_css_button'),
onclick: () => app.modal.show(EditCustomCssModal),
})}
{Button.component(
{
className: 'Button',
onclick: () => app.modal.show(EditCustomCssModal),
},
app.translator.trans('core.admin.appearance.edit_css_button')
)}
</fieldset>
</div>
</div>

View File

@@ -5,10 +5,11 @@ import Button from '../../common/components/Button';
import saveSettings from '../utils/saveSettings';
import ItemList from '../../common/utils/ItemList';
import Switch from '../../common/components/Switch';
import withAttr from '../../common/utils/withAttr';
export default class BasicsPage extends Page {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
this.loading = false;
@@ -25,7 +26,7 @@ export default class BasicsPage extends Page {
this.values = {};
const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = m.prop(settings[key])));
this.fields.forEach((key) => (this.values[key] = m.stream(settings[key])));
this.localeOptions = {};
const locales = app.data.locales;
@@ -49,45 +50,51 @@ export default class BasicsPage extends Page {
<div className="BasicsPage">
<div className="container">
<form onsubmit={this.onsubmit.bind(this)}>
{FieldSet.component({
label: app.translator.trans('core.admin.basics.forum_title_heading'),
children: [<input className="FormControl" value={this.values.forum_title()} oninput={m.withAttr('value', this.values.forum_title)} />],
})}
{FieldSet.component(
{
label: app.translator.trans('core.admin.basics.forum_title_heading'),
},
[<input className="FormControl" bidi={this.values.forum_title} />]
)}
{FieldSet.component({
label: app.translator.trans('core.admin.basics.forum_description_heading'),
children: [
{FieldSet.component(
{
label: app.translator.trans('core.admin.basics.forum_description_heading'),
},
[
<div className="helpText">{app.translator.trans('core.admin.basics.forum_description_text')}</div>,
<textarea
className="FormControl"
value={this.values.forum_description()}
oninput={m.withAttr('value', this.values.forum_description)}
/>,
],
})}
<textarea className="FormControl" bidi={this.values.forum_description} />,
]
)}
{Object.keys(this.localeOptions).length > 1
? FieldSet.component({
label: app.translator.trans('core.admin.basics.default_language_heading'),
children: [
? FieldSet.component(
{
label: app.translator.trans('core.admin.basics.default_language_heading'),
},
[
Select.component({
options: this.localeOptions,
value: this.values.default_locale(),
onchange: this.values.default_locale,
}),
Switch.component({
state: this.values.show_language_selector(),
onchange: this.values.show_language_selector,
children: app.translator.trans('core.admin.basics.show_language_selector_label'),
}),
],
})
Switch.component(
{
state: this.values.show_language_selector(),
onchange: this.values.show_language_selector,
},
app.translator.trans('core.admin.basics.show_language_selector_label')
),
]
)
: ''}
{FieldSet.component({
label: app.translator.trans('core.admin.basics.home_page_heading'),
className: 'BasicsPage-homePage',
children: [
{FieldSet.component(
{
label: app.translator.trans('core.admin.basics.home_page_heading'),
className: 'BasicsPage-homePage',
},
[
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>,
this.homePageItems()
.toArray()
@@ -98,51 +105,52 @@ export default class BasicsPage extends Page {
name="homePage"
value={path}
checked={this.values.default_route() === path}
onclick={m.withAttr('value', this.values.default_route)}
onclick={withAttr('value', this.values.default_route)}
/>
{label}
</label>
)),
],
})}
]
)}
{FieldSet.component({
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
className: 'BasicsPage-welcomeBanner',
children: [
{FieldSet.component(
{
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
className: 'BasicsPage-welcomeBanner',
},
[
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>,
<div className="BasicsPage-welcomeBanner-input">
<input className="FormControl" value={this.values.welcome_title()} oninput={m.withAttr('value', this.values.welcome_title)} />
<textarea
className="FormControl"
value={this.values.welcome_message()}
oninput={m.withAttr('value', this.values.welcome_message)}
/>
<input className="FormControl" bidi={this.values.welcome_title} />
<textarea className="FormControl" bidi={this.values.welcome_message} />
</div>,
],
})}
]
)}
{Object.keys(this.displayNameOptions).length > 1
? FieldSet.component({
label: app.translator.trans('core.admin.basics.display_name_heading'),
children: [
? FieldSet.component(
{
label: app.translator.trans('core.admin.basics.display_name_heading'),
},
[
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>,
Select.component({
options: this.displayNameOptions,
value: this.values.display_name_driver(),
onchange: this.values.display_name_driver,
bidi: this.values.display_name_driver,
}),
],
})
]
)
: ''}
{Button.component({
type: 'submit',
className: 'Button Button--primary',
children: app.translator.trans('core.admin.basics.submit_button'),
loading: this.loading,
disabled: !this.changed(),
})}
{Button.component(
{
type: 'submit',
className: 'Button Button--primary',
loading: this.loading,
disabled: !this.changed(),
},
app.translator.trans('core.admin.basics.submit_button')
)}
</form>
</div>
</div>
@@ -185,9 +193,8 @@ export default class BasicsPage extends Page {
saveSettings(settings)
.then(() => {
this.successAlert = app.alerts.show({
this.successAlert = app.alerts.show(app.translator.trans('core.admin.basics.saved_message'), {
type: 'success',
children: app.translator.trans('core.admin.basics.saved_message'),
});
})
.catch(() => {})

View File

@@ -4,20 +4,23 @@ import Badge from '../../common/components/Badge';
import Group from '../../common/models/Group';
import ItemList from '../../common/utils/ItemList';
import Switch from '../../common/components/Switch';
import withAttr from '../../common/utils/withAttr';
/**
* The `EditGroupModal` component shows a modal dialog which allows the user
* to create or edit a group.
*/
export default class EditGroupModal extends Modal {
init() {
this.group = this.props.group || app.store.createRecord('groups');
oninit(vnode) {
super.oninit(vnode);
this.nameSingular = m.prop(this.group.nameSingular() || '');
this.namePlural = m.prop(this.group.namePlural() || '');
this.icon = m.prop(this.group.icon() || '');
this.color = m.prop(this.group.color() || '');
this.isHidden = m.prop(this.group.isHidden() || false);
this.group = this.attrs.group || app.store.createRecord('groups');
this.nameSingular = m.stream(this.group.nameSingular() || '');
this.namePlural = m.stream(this.group.namePlural() || '');
this.icon = m.stream(this.group.icon() || '');
this.color = m.stream(this.group.color() || '');
this.isHidden = m.stream(this.group.isHidden() || false);
}
className() {
@@ -53,18 +56,8 @@ export default class EditGroupModal extends Modal {
<div className="Form-group">
<label>{app.translator.trans('core.admin.edit_group.name_label')}</label>
<div className="EditGroupModal-name-input">
<input
className="FormControl"
placeholder={app.translator.trans('core.admin.edit_group.singular_placeholder')}
value={this.nameSingular()}
oninput={m.withAttr('value', this.nameSingular)}
/>
<input
className="FormControl"
placeholder={app.translator.trans('core.admin.edit_group.plural_placeholder')}
value={this.namePlural()}
oninput={m.withAttr('value', this.namePlural)}
/>
<input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.singular_placeholder')} bidi={this.nameSingular} />
<input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.plural_placeholder')} bidi={this.namePlural} />
</div>
</div>,
30
@@ -74,7 +67,7 @@ export default class EditGroupModal extends Modal {
'color',
<div className="Form-group">
<label>{app.translator.trans('core.admin.edit_group.color_label')}</label>
<input className="FormControl" placeholder="#aaaaaa" value={this.color()} oninput={m.withAttr('value', this.color)} />
<input className="FormControl" placeholder="#aaaaaa" bidi={this.color} />
</div>,
20
);
@@ -86,7 +79,7 @@ export default class EditGroupModal extends Modal {
<div className="helpText">
{app.translator.trans('core.admin.edit_group.icon_text', { a: <a href="https://fontawesome.com/icons?m=free" tabindex="-1" /> })}
</div>
<input className="FormControl" placeholder="fas fa-bolt" value={this.icon()} oninput={m.withAttr('value', this.icon)} />
<input className="FormControl" placeholder="fas fa-bolt" bidi={this.icon} />
</div>,
10
);
@@ -94,11 +87,13 @@ export default class EditGroupModal extends Modal {
items.add(
'hidden',
<div className="Form-group">
{Switch.component({
state: !!Number(this.isHidden()),
children: app.translator.trans('core.admin.edit_group.hide_label'),
onchange: this.isHidden,
})}
{Switch.component(
{
state: !!Number(this.isHidden()),
onchange: this.isHidden,
},
app.translator.trans('core.admin.edit_group.hide_label')
)}
</div>,
10
);
@@ -106,12 +101,14 @@ export default class EditGroupModal extends Modal {
items.add(
'submit',
<div className="Form-group">
{Button.component({
type: 'submit',
className: 'Button Button--primary EditGroupModal-save',
loading: this.loading,
children: app.translator.trans('core.admin.edit_group.submit_button'),
})}
{Button.component(
{
type: 'submit',
className: 'Button Button--primary EditGroupModal-save',
loading: this.loading,
},
app.translator.trans('core.admin.edit_group.submit_button')
)}
{this.group.exists && this.group.id() !== Group.ADMINISTRATOR_ID ? (
<button type="button" className="Button EditGroupModal-delete" onclick={this.deleteGroup.bind(this)}>
{app.translator.trans('core.admin.edit_group.delete_button')}

View File

@@ -12,12 +12,14 @@ export default class ExtensionsPage extends Page {
<div className="ExtensionsPage">
<div className="ExtensionsPage-header">
<div className="container">
{Button.component({
children: app.translator.trans('core.admin.extensions.add_button'),
icon: 'fas fa-plus',
className: 'Button Button--primary',
onclick: () => app.modal.show(AddExtensionModal),
})}
{Button.component(
{
icon: 'fas fa-plus',
className: 'Button Button--primary',
onclick: () => app.modal.show(AddExtensionModal),
},
app.translator.trans('core.admin.extensions.add_button')
)}
</div>
</div>
@@ -72,31 +74,35 @@ export default class ExtensionsPage extends Page {
if (app.extensionSettings[name]) {
items.add(
'settings',
Button.component({
icon: 'fas fa-cog',
children: app.translator.trans('core.admin.extensions.settings_button'),
onclick: app.extensionSettings[name],
})
Button.component(
{
icon: 'fas fa-cog',
onclick: app.extensionSettings[name],
},
app.translator.trans('core.admin.extensions.settings_button')
)
);
}
if (!enabled) {
items.add(
'uninstall',
Button.component({
icon: 'far fa-trash-alt',
children: app.translator.trans('core.admin.extensions.uninstall_button'),
onclick: () => {
app
.request({
url: app.forum.attribute('apiUrl') + '/extensions/' + name,
method: 'DELETE',
})
.then(() => window.location.reload());
Button.component(
{
icon: 'far fa-trash-alt',
onclick: () => {
app
.request({
url: app.forum.attribute('apiUrl') + '/extensions/' + name,
method: 'DELETE',
})
.then(() => window.location.reload());
app.modal.show(LoadingModal);
app.modal.show(LoadingModal);
},
},
})
app.translator.trans('core.admin.extensions.uninstall_button')
)
);
}
@@ -116,7 +122,7 @@ export default class ExtensionsPage extends Page {
.request({
url: app.forum.attribute('apiUrl') + '/extensions/' + id,
method: 'PATCH',
data: { enabled: !enabled },
body: { enabled: !enabled },
})
.then(() => {
if (!enabled) localStorage.setItem('enabledExtension', id);

View File

@@ -11,13 +11,6 @@ export default class HeaderSecondary extends Component {
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
}
config(isInitialized, context) {
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
}
/**
* Build an item list for the controls.
*

View File

@@ -5,10 +5,11 @@ import Alert from '../../common/components/Alert';
import Select from '../../common/components/Select';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import saveSettings from '../utils/saveSettings';
import withAttr from '../../common/utils/withAttr';
export default class MailPage extends Page {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
this.saving = false;
this.sendingTest = false;
@@ -24,7 +25,7 @@ export default class MailPage extends Page {
this.status = { sending: false, errors: {} };
const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = m.prop(settings[key])));
this.fields.forEach((key) => (this.values[key] = m.stream(settings[key])));
app
.request({
@@ -39,7 +40,7 @@ export default class MailPage extends Page {
for (const driver in this.driverFields) {
for (const field in this.driverFields[driver]) {
this.fields.push(field);
this.values[field] = m.prop(settings[field]);
this.values[field] = m.stream(settings[field]);
}
}
@@ -69,23 +70,27 @@ export default class MailPage extends Page {
<h2>{app.translator.trans('core.admin.email.heading')}</h2>
<div className="helpText">{app.translator.trans('core.admin.email.text')}</div>
{FieldSet.component({
label: app.translator.trans('core.admin.email.addresses_heading'),
className: 'MailPage-MailSettings',
children: [
{FieldSet.component(
{
label: app.translator.trans('core.admin.email.addresses_heading'),
className: 'MailPage-MailSettings',
},
[
<div className="MailPage-MailSettings-input">
<label>
{app.translator.trans('core.admin.email.from_label')}
<input className="FormControl" value={this.values.mail_from() || ''} oninput={m.withAttr('value', this.values.mail_from)} />
<input className="FormControl" bidi={this.values.mail_from} />
</label>
</div>,
],
})}
]
)}
{FieldSet.component({
label: app.translator.trans('core.admin.email.driver_heading'),
className: 'MailPage-MailSettings',
children: [
{FieldSet.component(
{
label: app.translator.trans('core.admin.email.driver_heading'),
className: 'MailPage-MailSettings',
},
[
<div className="MailPage-MailSettings-input">
<label>
{app.translator.trans('core.admin.email.driver_label')}
@@ -96,20 +101,24 @@ export default class MailPage extends Page {
/>
</label>
</div>,
],
})}
]
)}
{this.status.sending ||
Alert.component({
children: app.translator.trans('core.admin.email.not_sending_message'),
dismissible: false,
})}
Alert.component(
{
dismissible: false,
},
app.translator.trans('core.admin.email.not_sending_message')
)}
{fieldKeys.length > 0 &&
FieldSet.component({
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
className: 'MailPage-MailSettings',
children: [
FieldSet.component(
{
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
className: 'MailPage-MailSettings',
},
[
<div className="MailPage-MailSettings-input">
{fieldKeys.map((field) => [
<label>
@@ -119,31 +128,37 @@ export default class MailPage extends Page {
this.status.errors[field] && <p className="ValidationError">{this.status.errors[field]}</p>,
])}
</div>,
],
})}
]
)}
<FieldSet>
{Button.component({
type: 'submit',
className: 'Button Button--primary',
children: app.translator.trans('core.admin.email.submit_button'),
disabled: !this.changed(),
})}
{Button.component(
{
type: 'submit',
className: 'Button Button--primary',
disabled: !this.changed(),
},
app.translator.trans('core.admin.email.submit_button')
)}
</FieldSet>
{FieldSet.component({
label: app.translator.trans('core.admin.email.send_test_mail_heading'),
className: 'MailPage-MailSettings',
children: [
{FieldSet.component(
{
label: app.translator.trans('core.admin.email.send_test_mail_heading'),
className: 'MailPage-MailSettings',
},
[
<div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user.email() })}</div>,
Button.component({
className: 'Button Button--primary',
children: app.translator.trans('core.admin.email.send_test_mail_button'),
disabled: this.sendingTest || this.changed(),
onclick: () => this.sendTestEmail(),
}),
],
})}
Button.component(
{
className: 'Button Button--primary',
disabled: this.sendingTest || this.changed(),
onclick: () => this.sendTestEmail(),
},
app.translator.trans('core.admin.email.send_test_mail_button')
),
]
)}
</form>
</div>
</div>
@@ -156,7 +171,7 @@ export default class MailPage extends Page {
const prop = this.values[name];
if (typeof field === 'string') {
return <input className="FormControl" value={prop() || ''} oninput={m.withAttr('value', prop)} />;
return <input className="FormControl" bidi={prop} />;
} else {
return <Select value={prop()} options={field} onchange={prop} />;
}
@@ -179,9 +194,8 @@ export default class MailPage extends Page {
})
.then((response) => {
this.sendingTest = false;
this.testEmailSuccessAlert = app.alerts.show({
this.testEmailSuccessAlert = app.alerts.show(app.translator.trans('core.admin.email.send_test_mail_success'), {
type: 'success',
children: app.translator.trans('core.admin.email.send_test_mail_success'),
});
})
.catch((error) => {
@@ -205,9 +219,8 @@ export default class MailPage extends Page {
saveSettings(settings)
.then(() => {
this.successAlert = app.alerts.show({
this.successAlert = app.alerts.show(app.translator.trans('core.admin.basics.saved_message'), {
type: 'success',
children: app.translator.trans('core.admin.basics.saved_message'),
});
})
.catch(() => {})

View File

@@ -32,101 +32,109 @@ function filterByRequiredPermissions(groupIds, permission) {
}
export default class PermissionDropdown extends Dropdown {
static initProps(props) {
super.initProps(props);
static initAttrs(attrs) {
super.initAttrs(attrs);
props.className = 'PermissionDropdown';
props.buttonClassName = 'Button Button--text';
attrs.className = 'PermissionDropdown';
attrs.buttonClassName = 'Button Button--text';
}
view() {
this.props.children = [];
view(vnode) {
const children = [];
let groupIds = app.data.permissions[this.props.permission] || [];
let groupIds = app.data.permissions[this.attrs.permission] || [];
groupIds = filterByRequiredPermissions(groupIds, this.props.permission);
groupIds = filterByRequiredPermissions(groupIds, this.attrs.permission);
const everyone = groupIds.indexOf(Group.GUEST_ID) !== -1;
const members = groupIds.indexOf(Group.MEMBER_ID) !== -1;
const adminGroup = app.store.getById('groups', Group.ADMINISTRATOR_ID);
if (everyone) {
this.props.label = Badge.component({ icon: 'fas fa-globe' });
this.attrs.label = Badge.component({ icon: 'fas fa-globe' });
} else if (members) {
this.props.label = Badge.component({ icon: 'fas fa-user' });
this.attrs.label = Badge.component({ icon: 'fas fa-user' });
} else {
this.props.label = [badgeForId(Group.ADMINISTRATOR_ID), groupIds.map(badgeForId)];
this.attrs.label = [badgeForId(Group.ADMINISTRATOR_ID), groupIds.map(badgeForId)];
}
if (this.showing) {
if (this.props.allowGuest) {
this.props.children.push(
Button.component({
children: [Badge.component({ icon: 'fas fa-globe' }), ' ', app.translator.trans('core.admin.permissions_controls.everyone_button')],
icon: everyone ? 'fas fa-check' : true,
onclick: () => this.save([Group.GUEST_ID]),
disabled: this.isGroupDisabled(Group.GUEST_ID),
})
if (this.attrs.allowGuest) {
children.push(
Button.component(
{
icon: everyone ? 'fas fa-check' : true,
onclick: () => this.save([Group.GUEST_ID]),
disabled: this.isGroupDisabled(Group.GUEST_ID),
},
[Badge.component({ icon: 'fas fa-globe' }), ' ', app.translator.trans('core.admin.permissions_controls.everyone_button')]
)
);
}
this.props.children.push(
Button.component({
children: [Badge.component({ icon: 'fas fa-user' }), ' ', app.translator.trans('core.admin.permissions_controls.members_button')],
icon: members ? 'fas fa-check' : true,
onclick: () => this.save([Group.MEMBER_ID]),
disabled: this.isGroupDisabled(Group.MEMBER_ID),
}),
children.push(
Button.component(
{
icon: members ? 'fas fa-check' : true,
onclick: () => this.save([Group.MEMBER_ID]),
disabled: this.isGroupDisabled(Group.MEMBER_ID),
},
[Badge.component({ icon: 'fas fa-user' }), ' ', app.translator.trans('core.admin.permissions_controls.members_button')]
),
Separator.component(),
Button.component({
children: [badgeForId(adminGroup.id()), ' ', adminGroup.namePlural()],
icon: !everyone && !members ? 'fas fa-check' : true,
disabled: !everyone && !members,
onclick: (e) => {
if (e.shiftKey) e.stopPropagation();
this.save([]);
Button.component(
{
icon: !everyone && !members ? 'fas fa-check' : true,
disabled: !everyone && !members,
onclick: (e) => {
if (e.shiftKey) e.stopPropagation();
this.save([]);
},
},
})
[badgeForId(adminGroup.id()), ' ', adminGroup.namePlural()]
)
);
[].push.apply(
this.props.children,
children,
app.store
.all('groups')
.filter((group) => [Group.ADMINISTRATOR_ID, Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
.map((group) =>
Button.component({
children: [badgeForId(group.id()), ' ', group.namePlural()],
icon: groupIds.indexOf(group.id()) !== -1 ? 'fas fa-check' : true,
onclick: (e) => {
if (e.shiftKey) e.stopPropagation();
this.toggle(group.id());
Button.component(
{
icon: groupIds.indexOf(group.id()) !== -1 ? 'fas fa-check' : true,
onclick: (e) => {
if (e.shiftKey) e.stopPropagation();
this.toggle(group.id());
},
disabled: this.isGroupDisabled(group.id()) && this.isGroupDisabled(Group.MEMBER_ID) && this.isGroupDisabled(Group.GUEST_ID),
},
disabled: this.isGroupDisabled(group.id()) && this.isGroupDisabled(Group.MEMBER_ID) && this.isGroupDisabled(Group.GUEST_ID),
})
[badgeForId(group.id()), ' ', group.namePlural()]
)
)
);
}
return super.view();
return super.view({ ...vnode, children });
}
save(groupIds) {
const permission = this.props.permission;
const permission = this.attrs.permission;
app.data.permissions[permission] = groupIds;
app.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/permission',
data: { permission, groupIds },
body: { permission, groupIds },
});
}
toggle(groupId) {
const permission = this.props.permission;
const permission = this.attrs.permission;
let groupIds = app.data.permissions[permission] || [];
@@ -143,6 +151,6 @@ export default class PermissionDropdown extends Dropdown {
}
isGroupDisabled(id) {
return filterByRequiredPermissions([id], this.props.permission).indexOf(id) === -1;
return filterByRequiredPermissions([id], this.attrs.permission).indexOf(id) === -1;
}
}

View File

@@ -6,7 +6,9 @@ import ItemList from '../../common/utils/ItemList';
import icon from '../../common/helpers/icon';
export default class PermissionGrid extends Component {
init() {
oninit(vnode) {
super.oninit(vnode);
this.permissions = this.permissionItems().toArray();
}

View File

@@ -9,18 +9,16 @@ import ItemList from '../../common/utils/ItemList';
* avatar/name, with a dropdown of session controls.
*/
export default class SessionDropdown extends Dropdown {
static initProps(props) {
super.initProps(props);
static initAttrs(attrs) {
super.initAttrs(attrs);
props.className = 'SessionDropdown';
props.buttonClassName = 'Button Button--user Button--flat';
props.menuClassName = 'Dropdown-menu--right';
attrs.className = 'SessionDropdown';
attrs.buttonClassName = 'Button Button--user Button--flat';
attrs.menuClassName = 'Dropdown-menu--right';
}
view() {
this.props.children = this.items().toArray();
return super.view();
view(vnode) {
return super.view({ ...vnode, children: this.items().toArray() });
}
getButtonContent() {
@@ -39,11 +37,13 @@ export default class SessionDropdown extends Dropdown {
items.add(
'logOut',
Button.component({
icon: 'fas fa-sign-out-alt',
children: app.translator.trans('core.admin.header.log_out_button'),
onclick: app.session.logout.bind(app.session),
}),
Button.component(
{
icon: 'fas fa-sign-out-alt',
onclick: app.session.logout.bind(app.session),
},
app.translator.trans('core.admin.header.log_out_button')
),
-100
);

View File

@@ -3,23 +3,30 @@ import Button from '../../common/components/Button';
import saveSettings from '../utils/saveSettings';
export default class SettingDropdown extends SelectDropdown {
static initProps(props) {
super.initProps(props);
static initAttrs(attrs) {
super.initAttrs(attrs);
props.className = 'SettingDropdown';
props.buttonClassName = 'Button Button--text';
props.caretIcon = 'fas fa-caret-down';
props.defaultLabel = 'Custom';
attrs.className = 'SettingDropdown';
attrs.buttonClassName = 'Button Button--text';
attrs.caretIcon = 'fas fa-caret-down';
attrs.defaultLabel = 'Custom';
}
props.children = props.options.map(({ value, label }) => {
const active = app.data.settings[props.key] === value;
view(vnode) {
return super.view({
...vnode,
children: this.attrs.options.map(({ value, label }) => {
const active = app.data.settings[this.attrs.key] === value;
return Button.component({
children: label,
icon: active ? 'fas fa-check' : true,
onclick: saveSettings.bind(this, { [props.key]: value }),
active,
});
return Button.component(
{
icon: active ? 'fas fa-check' : true,
onclick: saveSettings.bind(this, { [this.attrs.key]: value }),
active,
},
label
);
}),
});
}
}

View File

@@ -3,7 +3,9 @@ import Button from '../../common/components/Button';
import saveSettings from '../utils/saveSettings';
export default class SettingsModal extends Modal {
init() {
oninit(vnode) {
super.oninit(vnode);
this.settings = {};
this.loading = false;
}
@@ -33,7 +35,7 @@ export default class SettingsModal extends Modal {
}
setting(key, fallback = '') {
this.settings[key] = this.settings[key] || m.prop(app.data.settings[key] || fallback);
this.settings[key] = this.settings[key] || m.stream(app.data.settings[key] || fallback);
return this.settings[key];
}

View File

@@ -1,32 +1,28 @@
import Button from '../../common/components/Button';
export default class UploadImageButton extends Button {
init() {
this.loading = false;
}
loading = false;
view() {
this.props.loading = this.loading;
this.props.className = (this.props.className || '') + ' Button';
view(vnode) {
this.attrs.loading = this.loading;
this.attrs.className = (this.attrs.className || '') + ' Button';
if (app.data.settings[this.props.name + '_path']) {
this.props.onclick = this.remove.bind(this);
this.props.children = app.translator.trans('core.admin.upload_image.remove_button');
if (app.data.settings[this.attrs.name + '_path']) {
this.attrs.onclick = this.remove.bind(this);
return (
<div>
<p>
<img src={app.forum.attribute(this.props.name + 'Url')} alt="" />
<img src={app.forum.attribute(this.attrs.name + 'Url')} alt="" />
</p>
<p>{super.view()}</p>
<p>{super.view({ ...vnode, children: app.translator.trans('core.admin.upload_image.remove_button') })}</p>
</div>
);
} else {
this.props.onclick = this.upload.bind(this);
this.props.children = app.translator.trans('core.admin.upload_image.upload_button');
this.attrs.onclick = this.upload.bind(this);
}
return super.view();
return super.view({ ...vnode, children: app.translator.trans('core.admin.upload_image.upload_button') });
}
/**
@@ -42,8 +38,8 @@ export default class UploadImageButton extends Button {
.hide()
.click()
.on('change', (e) => {
const data = new FormData();
data.append(this.props.name, $(e.target)[0].files[0]);
const body = new FormData();
body.append(this.attrs.name, $(e.target)[0].files[0]);
this.loading = true;
m.redraw();
@@ -53,7 +49,7 @@ export default class UploadImageButton extends Button {
method: 'POST',
url: this.resourceUrl(),
serialize: (raw) => raw,
data,
body,
})
.then(this.success.bind(this), this.failure.bind(this));
});
@@ -75,7 +71,7 @@ export default class UploadImageButton extends Button {
}
resourceUrl() {
return app.forum.attribute('apiUrl') + '/' + this.props.name;
return app.forum.attribute('apiUrl') + '/' + this.attrs.name;
}
/**

View File

@@ -12,11 +12,11 @@ import MailPage from './components/MailPage';
*/
export default function (app) {
app.routes = {
dashboard: { path: '/', component: DashboardPage.component() },
basics: { path: '/basics', component: BasicsPage.component() },
permissions: { path: '/permissions', component: PermissionsPage.component() },
appearance: { path: '/appearance', component: AppearancePage.component() },
extensions: { path: '/extensions', component: ExtensionsPage.component() },
mail: { path: '/mail', component: MailPage.component() },
dashboard: { path: '/', component: DashboardPage },
basics: { path: '/basics', component: BasicsPage },
permissions: { path: '/permissions', component: PermissionsPage },
appearance: { path: '/appearance', component: AppearancePage },
extensions: { path: '/extensions', component: ExtensionsPage },
mail: { path: '/mail', component: MailPage },
};
}

View File

@@ -7,7 +7,7 @@ export default function saveSettings(settings) {
.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/settings',
data: settings,
body: settings,
})
.catch((error) => {
app.data.settings = oldSettings;

View File

@@ -189,8 +189,9 @@ export default class Application {
}
mount(basePath = '') {
m.mount(document.getElementById('modal'), <ModalManager state={this.modal} />);
m.mount(document.getElementById('alerts'), <AlertManager state={this.alerts} />);
// An object with a callable view property is used in order to pass arguments to the component; see https://mithril.js.org/mount.html
m.mount(document.getElementById('modal'), { view: () => ModalManager.component({ state: this.modal }) });
m.mount(document.getElementById('alerts'), { view: () => AlertManager.component({ state: this.alerts }) });
this.drawer = new Drawer();
@@ -263,7 +264,7 @@ export default class Application {
updateTitle() {
const count = this.titleCount ? `(${this.titleCount}) ` : '';
const pageTitleWithSeparator = this.title && m.route() !== '/' ? this.title + ' - ' : '';
const pageTitleWithSeparator = this.title && m.route.get() !== '/' ? this.title + ' - ' : '';
const title = this.forum.attribute('title');
document.title = count + pageTitleWithSeparator + title;
}
@@ -342,16 +343,14 @@ export default class Application {
// Now make the request. If it's a failure, inspect the error that was
// returned and show an alert containing its contents.
const deferred = m.deferred();
m.request(options).then(
(response) => deferred.resolve(response),
return m.request(options).then(
(response) => response,
(error) => {
let children;
let content;
switch (error.status) {
case 422:
children = error.response.errors
content = error.response.errors
.map((error) => [error.detail, <br />])
.reduce((a, b) => a.concat(b), [])
.slice(0, -1);
@@ -359,30 +358,31 @@ export default class Application {
case 401:
case 403:
children = app.translator.trans('core.lib.error.permission_denied_message');
content = app.translator.trans('core.lib.error.permission_denied_message');
break;
case 404:
case 410:
children = app.translator.trans('core.lib.error.not_found_message');
content = app.translator.trans('core.lib.error.not_found_message');
break;
case 429:
children = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
content = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
break;
default:
children = app.translator.trans('core.lib.error.generic_message');
content = app.translator.trans('core.lib.error.generic_message');
}
const isDebug = app.forum.attribute('debug');
// contains a formatted errors if possible, response must be an JSON API array of errors
// the details property is decoded to transform escaped characters such as '\n'
const formattedError = error.response && Array.isArray(error.response.errors) && error.response.errors.map((e) => decodeURI(e.detail));
const errors = error.response && error.response.errors;
const formattedError = Array.isArray(errors) && errors[0] && errors[0].detail && errors.map((e) => decodeURI(e.detail));
error.alert = {
type: 'error',
children,
content,
controls: isDebug && [
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error, formattedError)}>
Debug
@@ -404,14 +404,12 @@ export default class Application {
console.groupEnd();
}
this.requestErrorAlert = this.alerts.show(error.alert);
this.requestErrorAlert = this.alerts.show(error.alert.content, error.alert);
}
deferred.reject(error);
return Promise.reject(error);
}
);
return deferred.promise;
}
/**
@@ -434,9 +432,19 @@ export default class Application {
* @public
*/
route(name, params = {}) {
const url = this.routes[name].path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
const queryString = m.route.buildQueryString(params);
const prefix = m.route.mode === 'pathname' ? app.forum.attribute('basePath') : '';
const route = this.routes[name];
if (!route) throw new Error(`Route '${name}' does not exist`);
const url = route.path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
// Remove falsy values in params to avoid having urls like '/?sort&q'
for (const key in params) {
if (params.hasOwnProperty(key) && !params[key]) delete params[key];
}
const queryString = m.buildQueryString(params);
const prefix = m.route.prefix === '' ? this.forum.attribute('basePath') : '';
return prefix + url + (queryString ? '?' + queryString : '');
}

View File

@@ -1,221 +0,0 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
/**
* The `Component` class defines a user interface 'building block'. A component
* can generate a virtual DOM to be rendered on each redraw.
*
* An instance's virtual DOM can be retrieved directly using the {@link
* Component#render} method.
*
* @example
* this.myComponentInstance = new MyComponent({foo: 'bar'});
* return m('div', this.myComponentInstance.render());
*
* Alternatively, components can be nested, letting Mithril take care of
* instance persistence. For this, the static {@link Component.component} method
* can be used.
*
* @example
* return m('div', MyComponent.component({foo: 'bar'));
*
* @see https://lhorie.github.io/mithril/mithril.component.html
* @abstract
*/
export default class Component {
/**
* @param {Object} props
* @param {Array|Object} children
* @public
*/
constructor(props = {}, children = null) {
if (children) props.children = children;
this.constructor.initProps(props);
/**
* The properties passed into the component.
*
* @type {Object}
*/
this.props = props;
/**
* The root DOM element for the component.
*
* @type DOMElement
* @public
*/
this.element = null;
/**
* Whether or not to retain the component's subtree on redraw.
*
* @type {boolean}
* @public
*/
this.retain = false;
this.init();
}
/**
* Called when the component is constructed.
*
* @protected
*/
init() {}
/**
* Called when the component is destroyed, i.e. after a redraw where it is no
* longer a part of the view.
*
* @see https://lhorie.github.io/mithril/mithril.component.html#unloading-components
* @param {Object} e
* @public
*/
onunload() {}
/**
* Get the renderable virtual DOM that represents the component's view.
*
* This should NOT be overridden by subclasses. Subclasses wishing to define
* their virtual DOM should override Component#view instead.
*
* @example
* this.myComponentInstance = new MyComponent({foo: 'bar'});
* return m('div', this.myComponentInstance.render());
*
* @returns {Object}
* @final
* @public
*/
render() {
const vdom = this.retain ? { subtree: 'retain' } : this.view();
// Override the root element's config attribute with our own function, which
// will set the component instance's element property to the root DOM
// element, and then run the component class' config method.
vdom.attrs = vdom.attrs || {};
const originalConfig = vdom.attrs.config;
vdom.attrs.config = (...args) => {
this.element = args[0];
this.config.apply(this, args.slice(1));
if (originalConfig) originalConfig.apply(this, args);
};
return vdom;
}
/**
* Returns a jQuery object for this component's element. If you pass in a
* selector string, this method will return a jQuery object, using the current
* element as its buffer.
*
* For example, calling `component.$('li')` will return a jQuery object
* containing all of the `li` elements inside the DOM element of this
* component.
*
* @param {String} [selector] a jQuery-compatible selector string
* @returns {jQuery} the jQuery object for the DOM node
* @final
* @public
*/
$(selector) {
const $element = $(this.element);
return selector ? $element.find(selector) : $element;
}
/**
* Called after the component's root element is redrawn. This hook can be used
* to perform any actions on the DOM, both on the initial draw and any
* subsequent redraws. See Mithril's documentation for more information.
*
* @see https://lhorie.github.io/mithril/mithril.html#the-config-attribute
* @param {Boolean} isInitialized
* @param {Object} context
* @param {Object} vdom
* @public
*/
config() {}
/**
* Get the virtual DOM that represents the component's view.
*
* @return {Object} The virtual DOM
* @protected
*/
view() {
throw new Error('Component#view must be implemented by subclass');
}
/**
* Get a Mithril component object for this component, preloaded with props.
*
* @see https://lhorie.github.io/mithril/mithril.component.html
* @param {Object} [props] Properties to set on the component
* @param children
* @return {Object} The Mithril component object
* @property {function} controller
* @property {function} view
* @property {Object} component The class of this component
* @property {Object} props The props that were passed to the component
* @public
*/
static component(props = {}, children = null) {
const componentProps = Object.assign({}, props);
if (children) componentProps.children = children;
this.initProps(componentProps);
// Set up a function for Mithril to get the component's view. It will accept
// the component's controller (which happens to be the component itself, in
// our case), update its props with the ones supplied, and then render the view.
const view = (component) => {
component.props = componentProps;
return component.render();
};
// Mithril uses this property on the view function to cache component
// controllers between redraws, thus persisting component state.
view.$original = this.prototype.view;
// Our output object consists of a controller constructor + a view function
// which Mithril will use to instantiate and render the component. We also
// attach a reference to the props that were passed through and the
// component's class for reference.
const output = {
controller: this.bind(undefined, componentProps),
view: view,
props: componentProps,
component: this,
};
// If a `key` prop was set, then we'll assume that we want that to actually
// show up as an attribute on the component object so that Mithril's key
// algorithm can be applied.
if (componentProps.key) {
output.attrs = { key: componentProps.key };
}
return output;
}
/**
* Initialize the component's props.
*
* @param {Object} props
* @public
*/
static initProps(props) {}
}

136
js/src/common/Component.ts Normal file
View File

@@ -0,0 +1,136 @@
import * as Mithril from 'mithril';
export type ComponentAttrs = {
className?: string;
[key: string]: any;
};
/**
* The `Component` class defines a user interface 'building block'. A component
* generates a virtual DOM to be rendered on each redraw.
*
* Essentially, this is a wrapper for Mithril's components that adds several useful features:
*
* - In the `oninit` and `onbeforeupdate` lifecycle hooks, we store vnode attrs in `this.attrs.
* This allows us to use attrs across components without having to pass the vnode to every single
* method.
* - The static `initAttrs` method allows a convenient way to provide defaults (or to otherwise modify)
* the attrs that have been passed into a component.
* - When the component is created in the DOM, we store its DOM element under `this.element`; this lets
* us use jQuery to modify child DOM state from internal methods via the `this.$()` method.
* - A convenience `component` method, which serves as an alternative to hyperscript and JSX.
*
* As with other Mithril components, components extending Component can be initialized
* and nested using JSX, hyperscript, or a combination of both. The `component` method can also
* be used.
*
* @example
* return m('div', <MyComponent foo="bar"><p>Hello World</p></MyComponent>);
*
* @example
* return m('div', MyComponent.component({foo: 'bar'), m('p', 'Hello World!'));
*
* @see https://mithril.js.org/components.html
*/
export default abstract class Component<T extends ComponentAttrs = any> implements Mithril.ClassComponent<T> {
/**
* The root DOM element for the component.
*/
protected element!: Element;
/**
* The attributes passed into the component.
*
* @see https://mithril.js.org/components.html#passing-data-to-components
*/
protected attrs!: T;
/**
* @inheritdoc
*/
abstract view(vnode: Mithril.Vnode<T, this>): Mithril.Children;
/**
* @inheritdoc
*/
oninit(vnode: Mithril.Vnode<T, this>) {
this.setAttrs(vnode.attrs);
}
/**
* @inheritdoc
*/
oncreate(vnode: Mithril.VnodeDOM<T, this>) {
this.element = vnode.dom;
}
/**
* @inheritdoc
*/
onbeforeupdate(vnode: Mithril.VnodeDOM<T, this>) {
this.setAttrs(vnode.attrs);
}
/**
* Returns a jQuery object for this component's element. If you pass in a
* selector string, this method will return a jQuery object, using the current
* element as its buffer.
*
* For example, calling `component.$('li')` will return a jQuery object
* containing all of the `li` elements inside the DOM element of this
* component.
*
* @param {String} [selector] a jQuery-compatible selector string
* @returns {jQuery} the jQuery object for the DOM node
* @final
*/
protected $(selector) {
const $element = $(this.element);
return selector ? $element.find(selector) : $element;
}
/**
* Convenience method to attach a component without JSX.
* Has the same effect as calling `m(THIS_CLASS, attrs, children)`.
*
* @see https://mithril.js.org/hyperscript.html#mselector,-attributes,-children
*/
static component(attrs = {}, children = null): Mithril.Vnode {
const componentAttrs = Object.assign({}, attrs);
return m(this as any, componentAttrs, children);
}
/**
* Saves a reference to the vnode attrs after running them through initAttrs,
* and checking for common issues.
*/
private setAttrs(attrs: T = {} as T): void {
(this.constructor as typeof Component).initAttrs(attrs);
if (attrs) {
if ('children' in attrs) {
throw new Error(
`[${
(this.constructor as any).name
}] The "children" attribute of attrs should never be used. Either pass children in as the vnode children or rename the attribute`
);
}
if ('tag' in attrs) {
throw new Error(`[${(this.constructor as any).name}] You cannot use the "tag" attribute name with Mithril 2.`);
}
}
this.attrs = attrs;
}
/**
* Initialize the component's attrs.
*
* This can be used to assign default values for missing, optional attrs.
*/
protected static initAttrs<T>(attrs: T): void {}
}

75
js/src/common/Fragment.ts Normal file
View File

@@ -0,0 +1,75 @@
import * as Mithril from 'mithril';
/**
* The `Fragment` class represents a chunk of DOM that is rendered once with Mithril and then takes
* over control of its own DOM and lifecycle.
*
* This is very similar to the `Component` wrapper class, but is used for more fine-grained control over
* the rendering and display of some significant chunks of the DOM. In contrast to components, fragments
* do not offer Mithril's lifecycle hooks.
*
* Use this when you want to enjoy the benefits of JSX / VDOM for initial rendering, combined with
* small helper methods that then make updates to that DOM directly, instead of fully redrawing
* everything through Mithril.
*
* This should only be used when necessary, and only with `m.render`. If you are unsure whether you need
* this or `Component, you probably need `Component`.
*/
export default abstract class Fragment implements Mithril.ClassComponent {
/**
* The root DOM element for the fragment.
*/
protected element!: Element;
/**
* Returns a jQuery object for this fragment's element. If you pass in a
* selector string, this method will return a jQuery object, using the current
* element as its buffer.
*
* For example, calling `fragment.$('li')` will return a jQuery object
* containing all of the `li` elements inside the DOM element of this
* fragment.
*
* @param {String} [selector] a jQuery-compatible selector string
* @returns {jQuery} the jQuery object for the DOM node
* @final
*/
public $(selector) {
const $element = $(this.element);
return selector ? $element.find(selector) : $element;
}
/**
* Get the renderable virtual DOM that represents the fragment's view.
*
* This should NOT be overridden by subclasses. Subclasses wishing to define
* their virtual DOM should override Fragment#view instead.
*
* @example
* const fragment = new MyFragment();
* m.render(document.body, fragment.render());
*
* @final
*/
public render(): Mithril.Vnode {
const vdom = this.view();
vdom.attrs = vdom.attrs || {};
const originalOnCreate = vdom.attrs.oncreate;
vdom.attrs.oncreate = (vnode) => {
this.element = vnode.dom;
if (this.oncreate) this.oncreate.apply(this, vnode);
if (originalOnCreate) originalOnCreate.apply(this, vnode);
};
return vdom;
}
/**
* @inheritdoc
*/
abstract view();
}

View File

@@ -161,7 +161,7 @@ export default class Model {
{
method: this.exists ? 'PATCH' : 'POST',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
data: request,
body: request,
},
options
)
@@ -180,7 +180,7 @@ export default class Model {
// old data! We'll revert to that and let others handle the error.
(response) => {
this.pushData(oldData);
m.lazyRedraw();
m.redraw();
throw response;
}
);
@@ -189,13 +189,13 @@ export default class Model {
/**
* Send a request to delete the resource.
*
* @param {Object} data Data to send along with the DELETE request.
* @param {Object} body Data to send along with the DELETE request.
* @param {Object} [options]
* @return {Promise}
* @public
*/
delete(data, options = {}) {
if (!this.exists) return m.deferred().resolve().promise;
delete(body, options = {}) {
if (!this.exists) return Promise.resolve();
return app
.request(
@@ -203,7 +203,7 @@ export default class Model {
{
method: 'DELETE',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
data,
body,
},
options
)

View File

@@ -30,13 +30,13 @@ export default class Session {
* @return {Promise}
* @public
*/
login(data, options = {}) {
login(body, options = {}) {
return app.request(
Object.assign(
{
method: 'POST',
url: app.forum.attribute('baseUrl') + '/login',
data,
url: `${app.forum.attribute('baseUrl')}/login`,
body,
},
options
)
@@ -49,6 +49,6 @@ export default class Session {
* @public
*/
logout() {
window.location = app.forum.attribute('baseUrl') + '/logout?token=' + this.csrfToken;
window.location = `${app.forum.attribute('baseUrl')}/logout?token=${this.csrfToken}`;
}
}

View File

@@ -82,13 +82,13 @@ export default class Store {
* @public
*/
find(type, id, query = {}, options = {}) {
let data = query;
let params = query;
let url = app.forum.attribute('apiUrl') + '/' + type;
if (id instanceof Array) {
url += '?filter[id]=' + id.join(',');
} else if (typeof id === 'object') {
data = id;
params = id;
} else if (id) {
url += '/' + id;
}
@@ -99,7 +99,7 @@ export default class Store {
{
method: 'GET',
url,
data,
params,
},
options
)

View File

@@ -1,4 +1,3 @@
import User from './models/User';
import username from './helpers/username';
import extract from './utils/extract';
@@ -71,18 +70,34 @@ export default class Translator {
const match = part.match(new RegExp('{([a-z0-9_]+)}|<(/?)([a-z0-9_]+)>', 'i'));
if (match) {
// Either an opening or closing tag.
if (match[1]) {
open[0].push(input[match[1]]);
} else if (match[3]) {
if (match[2]) {
// Closing tag. We start by removing all raw children (generally in the form of strings) from the temporary
// holding array, then run them through m.fragment to convert them to vnodes. Usually this will just give us a
// text vnode, but using m.fragment as opposed to an explicit conversion should be more flexible. This is necessary because
// otherwise, our generated vnode will have raw strings as its children, and mithril expects vnodes.
// Finally, we add the now-processed vnodes back onto the holding array (which is the same object in memory as the
// children array of the vnode we are currently processing), and remove the reference to the holding array so that
// further text will be added to the full set of returned elements.
const rawChildren = open[0].splice(0, open[0].length);
open[0].push(...m.fragment(rawChildren).children);
open.shift();
} else {
// If a vnode with a matching tag was provided in the translator input, we use that. Otherwise, we create a new vnode
// with this tag, and an empty children array (since we're expecting to insert children, as that's the point of having this in translator)
let tag = input[match[3]] || { tag: match[3], children: [] };
open[0].push(tag);
// Insert the tag's children array as the first element of open, so that text in between the opening
// and closing tags will be added to the tag's children, not to the full set of returned elements.
open.unshift(tag.children || tag);
}
}
} else {
// Not an html tag, we add it to open[0], which is either the full set of returned elements (vnodes and text),
// or if an html tag is currently being processed, the children attribute of that html tag's vnode.
open[0].push(part);
}
});

View File

@@ -13,6 +13,7 @@ import RequestError from './utils/RequestError';
import abbreviateNumber from './utils/abbreviateNumber';
import * as string from './utils/string';
import SubtreeRetainer from './utils/SubtreeRetainer';
import setRouteWithForcedRefresh from './utils/setRouteWithForcedRefresh';
import extract from './utils/extract';
import ScrollListener from './utils/ScrollListener';
import stringToColor from './utils/stringToColor';
@@ -22,6 +23,7 @@ import classList from './utils/classList';
import extractText from './utils/extractText';
import formatNumber from './utils/formatNumber';
import mapRoutes from './utils/mapRoutes';
import withAttr from './utils/withAttr';
import Notification from './models/Notification';
import User from './models/User';
import Post from './models/Post';
@@ -62,6 +64,7 @@ import highlight from './helpers/highlight';
import username from './helpers/username';
import userOnline from './helpers/userOnline';
import listItems from './helpers/listItems';
import Fragment from './Fragment';
export default {
extend: extend,
@@ -83,11 +86,13 @@ export default {
'utils/ScrollListener': ScrollListener,
'utils/stringToColor': stringToColor,
'utils/subclassOf': subclassOf,
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
'utils/patchMithril': patchMithril,
'utils/classList': classList,
'utils/extractText': extractText,
'utils/formatNumber': formatNumber,
'utils/mapRoutes': mapRoutes,
'utils/withAttr': withAttr,
'models/Notification': Notification,
'models/User': User,
'models/Post': Post,
@@ -95,6 +100,7 @@ export default {
'models/Group': Group,
'models/Forum': Forum,
Component: Component,
Fragment: Fragment,
Translator: Translator,
'components/AlertManager': AlertManager,
'components/Page': Page,

View File

@@ -7,7 +7,7 @@ import extract from '../utils/extract';
* The `Alert` component represents an alert box, which contains a message,
* some controls, and may be dismissible.
*
* The alert may have the following special props:
* ### Attrs
*
* - `type` The type of alert this is. Will be used to give the alert a class
* name of `Alert--{type}`.
@@ -15,16 +15,16 @@ import extract from '../utils/extract';
* - `dismissible` Whether or not the alert can be dismissed.
* - `ondismiss` A callback to run when the alert is dismissed.
*
* All other props will be assigned as attributes on the alert element.
* All other attrs will be assigned as attributes on the DOM element.
*/
export default class Alert extends Component {
view() {
const attrs = Object.assign({}, this.props);
view(vnode) {
const attrs = Object.assign({}, this.attrs);
const type = extract(attrs, 'type');
attrs.className = 'Alert Alert--' + type + ' ' + (attrs.className || '');
const children = extract(attrs, 'children');
const content = extract(attrs, 'content') || vnode.children;
const controls = extract(attrs, 'controls') || [];
// If the alert is meant to be dismissible (which is the case by default),
@@ -40,7 +40,7 @@ export default class Alert extends Component {
return (
<div {...attrs}>
<span className="Alert-body">{children}</span>
<span className="Alert-body">{content}</span>
<ul className="Alert-controls">{listItems(controls.concat(dismissControl))}</ul>
</div>
);

View File

@@ -6,8 +6,10 @@ import Alert from './Alert';
* be shown and dismissed.
*/
export default class AlertManager extends Component {
init() {
this.state = this.props.state;
oninit(vnode) {
super.oninit(vnode);
this.state = this.attrs.state;
}
view() {
@@ -15,17 +17,12 @@ export default class AlertManager extends Component {
<div className="AlertManager">
{Object.entries(this.state.getActiveAlerts()).map(([key, alert]) => (
<div className="AlertManager-alert">
{(alert.componentClass || Alert).component({ ...alert.attrs, ondismiss: this.state.dismiss.bind(this.state, key) })}
<alert.componentClass {...alert.attrs} ondismiss={this.state.dismiss.bind(this.state, key)}>
{alert.children}
</alert.componentClass>
</div>
))}
</div>
);
}
config(isInitialized, context) {
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
}
}

View File

@@ -6,18 +6,18 @@ import extract from '../utils/extract';
* The `Badge` component represents a user/discussion badge, indicating some
* status (e.g. a discussion is stickied, a user is an admin).
*
* A badge may have the following special props:
* A badge may have the following special attrs:
*
* - `type` The type of badge this is. This will be used to give the badge a
* class name of `Badge--{type}`.
* - `icon` The name of an icon to show inside the badge.
* - `label`
*
* All other props will be assigned as attributes on the badge element.
* All other attrs will be assigned as attributes on the badge element.
*/
export default class Badge extends Component {
view() {
const attrs = Object.assign({}, this.props);
const attrs = Object.assign({}, this.attrs);
const type = extract(attrs, 'type');
const iconName = extract(attrs, 'icon');
@@ -27,9 +27,9 @@ export default class Badge extends Component {
return <span {...attrs}>{iconName ? icon(iconName, { className: 'Badge-icon' }) : m.trust('&nbsp;')}</span>;
}
config(isInitialized) {
if (isInitialized) return;
oncreate(vnode) {
super.oncreate(vnode);
if (this.props.label) this.$().tooltip();
if (this.attrs.label) this.$().tooltip();
}
}

View File

@@ -1,12 +1,15 @@
import Component from '../Component';
import icon from '../helpers/icon';
import classList from '../utils/classList';
import extract from '../utils/extract';
import extractText from '../utils/extractText';
import LoadingIndicator from './LoadingIndicator';
/**
* The `Button` component defines an element which, when clicked, performs an
* action. The button may have the following special props:
* action.
*
* ### Attrs
*
* - `icon` The name of the icon class. If specified, the button will be given a
* 'has-icon' class name.
@@ -15,41 +18,38 @@ import LoadingIndicator from './LoadingIndicator';
* removed.
* - `loading` Whether or not the button should be in a disabled loading state.
*
* All other props will be assigned as attributes on the button element.
* All other attrs will be assigned as attributes on the button element.
*
* Note that a Button has no default class names. This is because a Button can
* be used to represent any generic clickable control, like a menu item.
*/
export default class Button extends Component {
view() {
const attrs = Object.assign({}, this.props);
view(vnode) {
const attrs = Object.assign({}, this.attrs);
delete attrs.children;
attrs.className = attrs.className || '';
attrs.type = attrs.type || 'button';
// If a tooltip was provided for buttons without additional content, we also
// use this tooltip as text for screen readers
if (attrs.title && !this.props.children) {
if (attrs.title && !vnode.children) {
attrs['aria-label'] = attrs.title;
}
// If nothing else is provided, we use the textual button content as tooltip
if (!attrs.title && this.props.children) {
attrs.title = extractText(this.props.children);
if (!attrs.title && vnode.children) {
attrs.title = extractText(vnode.children);
}
const iconName = extract(attrs, 'icon');
if (iconName) attrs.className += ' hasIcon';
const loading = extract(attrs, 'loading');
if (attrs.disabled || loading) {
attrs.className += ' disabled' + (loading ? ' loading' : '');
delete attrs.onclick;
}
return <button {...attrs}>{this.getButtonContent()}</button>;
attrs.className = classList([attrs.className, iconName && 'hasIcon', (attrs.disabled || loading) && 'disabled', loading && 'loading']);
return <button {...attrs}>{this.getButtonContent(vnode.children)}</button>;
}
/**
@@ -58,13 +58,13 @@ export default class Button extends Component {
* @return {*}
* @protected
*/
getButtonContent() {
const iconName = this.props.icon;
getButtonContent(children) {
const iconName = this.attrs.icon;
return [
iconName && iconName !== true ? icon(iconName, { className: 'Button-icon' }) : '',
this.props.children ? <span className="Button-label">{this.props.children}</span> : '',
this.props.loading ? LoadingIndicator.component({ size: 'tiny', className: 'LoadingIndicator--inline' }) : '',
children ? <span className="Button-label">{children}</span> : '',
this.attrs.loading ? <LoadingIndicator size="tiny" className="LoadingIndicator--inline" /> : '',
];
}
}

View File

@@ -1,11 +1,13 @@
import Component from '../Component';
import LoadingIndicator from './LoadingIndicator';
import icon from '../helpers/icon';
import classList from '../utils/classList';
import withAttr from '../utils/withAttr';
/**
* The `Checkbox` component defines a checkbox input.
*
* ### Props
* ### Attrs
*
* - `state` Whether or not the checkbox is checked.
* - `className` The class name for the root element.
@@ -15,19 +17,24 @@ import icon from '../helpers/icon';
* - `children` A text label to display next to the checkbox.
*/
export default class Checkbox extends Component {
view() {
view(vnode) {
// Sometimes, false is stored in the DB as '0'. This is a temporary
// conversion layer until a more robust settings encoding is introduced
if (this.props.state === '0') this.props.state = false;
let className = 'Checkbox ' + (this.props.state ? 'on' : 'off') + ' ' + (this.props.className || '');
if (this.props.loading) className += ' loading';
if (this.props.disabled) className += ' disabled';
if (this.attrs.state === '0') this.attrs.state = false;
const className = classList([
'Checkbox',
this.attrs.state ? 'on' : 'off',
this.attrs.className,
this.attrs.loading && 'loading',
this.attrs.disabled && 'disabled',
]);
return (
<label className={className}>
<input type="checkbox" checked={this.props.state} disabled={this.props.disabled} onchange={m.withAttr('checked', this.onchange.bind(this))} />
<input type="checkbox" checked={this.attrs.state} disabled={this.attrs.disabled} onchange={withAttr('checked', this.onchange.bind(this))} />
<div className="Checkbox-display">{this.getDisplay()}</div>
{this.props.children}
{vnode.children}
</label>
);
}
@@ -39,7 +46,7 @@ export default class Checkbox extends Component {
* @protected
*/
getDisplay() {
return this.props.loading ? LoadingIndicator.component({ size: 'tiny' }) : icon(this.props.state ? 'fas fa-check' : 'fas fa-times');
return this.attrs.loading ? <LoadingIndicator size="tiny" /> : icon(this.attrs.state ? 'fas fa-check' : 'fas fa-times');
}
/**
@@ -49,6 +56,6 @@ export default class Checkbox extends Component {
* @protected
*/
onchange(checked) {
if (this.props.onchange) this.props.onchange(checked, this);
if (this.attrs.onchange) this.attrs.onchange(checked, this);
}
}

View File

@@ -5,7 +5,7 @@ import Component from '../Component';
* event handler that prevents closing the browser window/tab based on the
* return value of a given callback prop.
*
* ### Props
* ### Attrs
*
* - `when` - a callback returning true when the browser should prompt for
* confirmation before closing the window/tab
@@ -17,21 +17,24 @@ import Component from '../Component';
*
*/
export default class ConfirmDocumentUnload extends Component {
config(isInitialized, context) {
if (isInitialized) return;
const handler = () => this.props.when() || undefined;
$(window).on('beforeunload', handler);
context.onunload = () => {
$(window).off('beforeunload', handler);
};
handler() {
return this.attrs.when() || undefined;
}
view() {
oncreate(vnode) {
super.oncreate(vnode);
this.boundHandler = this.handler.bind(this);
$(window).on('beforeunload', this.boundHandler);
}
onremove() {
$(window).off('beforeunload', this.boundHandler);
}
view(vnode) {
// To avoid having to render another wrapping <div> here, we assume that
// this component is only wrapped around a single element / component.
return this.props.children[0];
return vnode.children[0];
}
}

View File

@@ -6,7 +6,7 @@ import listItems from '../helpers/listItems';
* The `Dropdown` component displays a button which, when clicked, shows a
* dropdown menu beneath it.
*
* ### Props
* ### Attrs
*
* - `buttonClassName` A class name to apply to the dropdown toggle button.
* - `menuClassName` A class name to apply to the dropdown menu.
@@ -19,33 +19,33 @@ import listItems from '../helpers/listItems';
* The children will be displayed as a list inside of the dropdown menu.
*/
export default class Dropdown extends Component {
static initProps(props) {
super.initProps(props);
props.className = props.className || '';
props.buttonClassName = props.buttonClassName || '';
props.menuClassName = props.menuClassName || '';
props.label = props.label || '';
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-caret-down';
static initAttrs(attrs) {
attrs.className = attrs.className || '';
attrs.buttonClassName = attrs.buttonClassName || '';
attrs.menuClassName = attrs.menuClassName || '';
attrs.label = attrs.label || '';
attrs.caretIcon = typeof attrs.caretIcon !== 'undefined' ? attrs.caretIcon : 'fas fa-caret-down';
}
init() {
oninit(vnode) {
super.oninit(vnode);
this.showing = false;
}
view() {
const items = this.props.children ? listItems(this.props.children) : [];
view(vnode) {
const items = vnode.children ? listItems(vnode.children) : [];
return (
<div className={'ButtonGroup Dropdown dropdown ' + this.props.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}>
{this.getButton()}
<div className={'ButtonGroup Dropdown dropdown ' + this.attrs.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}>
{this.getButton(vnode.children)}
{this.getMenu(items)}
</div>
);
}
config(isInitialized) {
if (isInitialized) return;
oncreate(vnode) {
super.oncreate(vnode);
// When opening the dropdown menu, work out if the menu goes beyond the
// bottom of the viewport. If it does, we will apply class to make it show
@@ -53,8 +53,8 @@ export default class Dropdown extends Component {
this.$().on('shown.bs.dropdown', () => {
this.showing = true;
if (this.props.onshow) {
this.props.onshow();
if (this.attrs.onshow) {
this.attrs.onshow();
}
m.redraw();
@@ -76,8 +76,8 @@ export default class Dropdown extends Component {
this.$().on('hidden.bs.dropdown', () => {
this.showing = false;
if (this.props.onhide) {
this.props.onhide();
if (this.attrs.onhide) {
this.attrs.onhide();
}
m.redraw();
@@ -90,10 +90,10 @@ export default class Dropdown extends Component {
* @return {*}
* @protected
*/
getButton() {
getButton(children) {
return (
<button className={'Dropdown-toggle ' + this.props.buttonClassName} data-toggle="dropdown" onclick={this.props.onclick}>
{this.getButtonContent()}
<button className={'Dropdown-toggle ' + this.attrs.buttonClassName} data-toggle="dropdown" onclick={this.attrs.onclick}>
{this.getButtonContent(children)}
</button>
);
}
@@ -104,15 +104,15 @@ export default class Dropdown extends Component {
* @return {*}
* @protected
*/
getButtonContent() {
getButtonContent(children) {
return [
this.props.icon ? icon(this.props.icon, { className: 'Button-icon' }) : '',
<span className="Button-label">{this.props.label}</span>,
this.props.caretIcon ? icon(this.props.caretIcon, { className: 'Button-caret' }) : '',
this.attrs.icon ? icon(this.attrs.icon, { className: 'Button-icon' }) : '',
<span className="Button-label">{this.attrs.label}</span>,
this.attrs.caretIcon ? icon(this.attrs.caretIcon, { className: 'Button-caret' }) : '',
];
}
getMenu(items) {
return <ul className={'Dropdown-menu dropdown-menu ' + this.props.menuClassName}>{items}</ul>;
return <ul className={'Dropdown-menu dropdown-menu ' + this.attrs.menuClassName}>{items}</ul>;
}
}

View File

@@ -11,11 +11,11 @@ import listItems from '../helpers/listItems';
* The children should be an array of items to show in the fieldset.
*/
export default class FieldSet extends Component {
view() {
view(vnode) {
return (
<fieldset className={this.props.className}>
<legend>{this.props.label}</legend>
<ul>{listItems(this.props.children)}</ul>
<fieldset className={this.attrs.className}>
<legend>{this.attrs.label}</legend>
<ul>{listItems(vnode.children)}</ul>
</fieldset>
);
}

View File

@@ -1,16 +1,16 @@
import Badge from './Badge';
export default class GroupBadge extends Badge {
static initProps(props) {
super.initProps(props);
static initAttrs(attrs) {
super.initAttrs(attrs);
if (props.group) {
props.icon = props.group.icon();
props.style = { backgroundColor: props.group.color() };
props.label = typeof props.label === 'undefined' ? props.group.nameSingular() : props.label;
props.type = 'group--' + props.group.id();
if (attrs.group) {
attrs.icon = attrs.group.icon();
attrs.style = { backgroundColor: attrs.group.color() };
attrs.label = typeof attrs.label === 'undefined' ? attrs.group.nameSingular() : attrs.label;
attrs.type = 'group--' + attrs.group.id();
delete props.group;
delete attrs.group;
}
}
}

View File

@@ -3,9 +3,9 @@ import Button from './Button';
/**
* The `LinkButton` component defines a `Button` which links to a route.
*
* ### Props
* ### Attrs
*
* All of the props accepted by `Button`, plus:
* All of the attrs accepted by `Button`, plus:
*
* - `active` Whether or not the page that this button links to is currently
* active.
@@ -13,26 +13,28 @@ import Button from './Button';
* the `active` prop will automatically be set to true.
*/
export default class LinkButton extends Button {
static initProps(props) {
props.active = this.isActive(props);
props.config = props.config || m.route;
static initAttrs(attrs) {
super.initAttrs(attrs);
attrs.active = this.isActive(attrs);
}
view() {
const vdom = super.view();
view(vnode) {
const vdom = super.view(vnode);
vdom.tag = 'a';
vdom.tag = m.route.Link;
vdom.attrs.active = String(vdom.attrs.active);
return vdom;
}
/**
* Determine whether a component with the given props is 'active'.
* Determine whether a component with the given attrs is 'active'.
*
* @param {Object} props
* @param {Object} attrs
* @return {Boolean}
*/
static isActive(props) {
return typeof props.active !== 'undefined' ? props.active : m.route() === props.href;
static isActive(attrs) {
return typeof attrs.active !== 'undefined' ? attrs.active : m.route.get() === attrs.href;
}
}

View File

@@ -2,16 +2,17 @@ import Component from '../Component';
import { Spinner } from 'spin.js';
/**
* The `LoadingIndicator` component displays a loading spinner with spin.js. It
* may have the following special props:
* The `LoadingIndicator` component displays a loading spinner with spin.js.
*
* ### Attrs
*
* - `size` The spin.js size preset to use. Defaults to 'small'.
*
* All other props will be assigned as attributes on the element.
* All other attrs will be assigned as attributes on the DOM element.
*/
export default class LoadingIndicator extends Component {
view() {
const attrs = Object.assign({}, this.props);
const attrs = Object.assign({}, this.attrs);
attrs.className = 'LoadingIndicator ' + (attrs.className || '');
delete attrs.size;
@@ -19,12 +20,12 @@ export default class LoadingIndicator extends Component {
return <div {...attrs}>{m.trust('&nbsp;')}</div>;
}
config(isInitialized) {
if (isInitialized) return;
oncreate(vnode) {
super.oncreate(vnode);
const options = { zIndex: 'auto', color: this.$().css('color') };
switch (this.props.size) {
switch (this.attrs.size) {
case 'large':
Object.assign(options, { lines: 10, length: 8, width: 4, radius: 8 });
break;

View File

@@ -14,23 +14,21 @@ export default class Modal extends Component {
*/
static isDismissible = true;
init() {
/**
* Attributes for an alert component to show below the header.
*
* @type {object}
*/
this.alertAttrs = null;
/**
* Attributes for an alert component to show below the header.
*
* @type {object}
*/
alertAttrs = null;
oncreate(vnode) {
super.oncreate(vnode);
this.attrs.onshow(() => this.onready());
}
config(isInitialized, context) {
if (isInitialized) return;
this.props.onshow(() => this.onready());
context.onunload = () => {
this.props.onhide();
};
onremove() {
this.attrs.onhide();
}
view() {
@@ -105,11 +103,13 @@ export default class Modal extends Component {
this.$('form').find('input, select, textarea').first().focus().select();
}
onhide() {}
/**
* Hide the modal.
*/
hide() {
this.props.onhide();
this.attrs.onhide();
}
/**

View File

@@ -6,12 +6,8 @@ import Component from '../Component';
* overwrite the previous one.
*/
export default class ModalManager extends Component {
init() {
this.state = this.props.state;
}
view() {
const modal = this.state.modal;
const modal = this.attrs.state.modal;
return (
<div className="ModalManager modal fade">
@@ -20,22 +16,17 @@ export default class ModalManager extends Component {
);
}
config(isInitialized, context) {
if (isInitialized) return;
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
oncreate(vnode) {
super.oncreate(vnode);
// Ensure the modal state is notified about a closed modal, even when the
// DOM-based Bootstrap JavaScript code triggered the closing of the modal,
// e.g. via ESC key or a click on the modal backdrop.
this.$().on('hidden.bs.modal', this.state.close.bind(this.state));
this.$().on('hidden.bs.modal', this.attrs.state.close.bind(this.attrs.state));
}
animateShow(readyCallback) {
const dismissible = !!this.state.modal.componentClass.isDismissible;
const dismissible = !!this.attrs.state.modal.componentClass.isDismissible;
this.$()
.one('shown.bs.modal', readyCallback)

View File

@@ -11,7 +11,7 @@ import LinkButton from './LinkButton';
* If the app has a pane, it will also include a 'pin' button which toggles the
* pinned state of the pane.
*
* Accepts the following props:
* Accepts the following attrs:
*
* - `className` The name of a class to set on the root element.
* - `drawer` Whether or not to show a button to toggle the app's drawer if
@@ -23,7 +23,7 @@ export default class Navigation extends Component {
return (
<div
className={'Navigation ButtonGroup ' + (this.props.className || '')}
className={'Navigation ButtonGroup ' + (this.attrs.className || '')}
onmouseenter={pane && pane.show.bind(pane)}
onmouseleave={pane && pane.onmouseleave.bind(pane)}
>
@@ -32,13 +32,6 @@ export default class Navigation extends Component {
);
}
config(isInitialized, context) {
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
}
/**
* Get the back button.
*
@@ -54,7 +47,6 @@ export default class Navigation extends Component {
href: history.backUrl(),
icon: 'fas fa-chevron-left',
title: previous.title,
config: () => {},
onclick: (e) => {
if (e.shiftKey || e.ctrlKey || e.metaKey || e.which === 2) return;
e.preventDefault();
@@ -88,7 +80,7 @@ export default class Navigation extends Component {
* @protected
*/
getDrawerButton() {
if (!this.props.drawer) return '';
if (!this.attrs.drawer) return '';
const { drawer } = app;
const user = app.session.user;

View File

@@ -7,10 +7,14 @@ import PageState from '../states/PageState';
* @abstract
*/
export default class Page extends Component {
init() {
oninit(vnode) {
super.oninit(vnode);
app.previous = app.current;
app.current = new PageState(this.constructor);
this.onNewRoute();
app.drawer.hide();
app.modal.close();
@@ -22,13 +26,27 @@ export default class Page extends Component {
this.bodyClass = '';
}
config(isInitialized, context) {
if (isInitialized) return;
/**
* A collections of actions to run when the route changes.
* This is extracted here, and not hardcoded in oninit, as oninit is not called
* when a different route is handled by the same component, but we still need to
* adjust the current route name.
*/
onNewRoute() {
app.current.set('routeName', this.attrs.routeName);
}
oncreate(vnode) {
super.oncreate(vnode);
if (this.bodyClass) {
$('#app').addClass(this.bodyClass);
}
}
context.onunload = () => $('#app').removeClass(this.bodyClass);
onremove() {
if (this.bodyClass) {
$('#app').removeClass(this.bodyClass);
}
}
}

View File

@@ -4,7 +4,7 @@ import Component from '../Component';
* The `Placeholder` component displays a muted text with some call to action,
* usually used as an empty state.
*
* ### Props
* ### Attrs
*
* - `text`
*/
@@ -12,7 +12,7 @@ export default class Placeholder extends Component {
view() {
return (
<div className="Placeholder">
<p>{this.props.text}</p>
<p>{this.attrs.text}</p>
</div>
);
}

View File

@@ -6,11 +6,11 @@ export default class RequestErrorModal extends Modal {
}
title() {
return this.props.error.xhr ? `${this.props.error.xhr.status} ${this.props.error.xhr.statusText}` : '';
return this.attrs.error.xhr ? `${this.attrs.error.xhr.status} ${this.attrs.error.xhr.statusText}` : '';
}
content() {
const { error, formattedError } = this.props;
const { error, formattedError } = this.attrs;
let responseText;
@@ -31,7 +31,7 @@ export default class RequestErrorModal extends Modal {
return (
<div className="Modal-body">
<pre>
{this.props.error.options.method} {this.props.error.options.url}
{this.attrs.error.options.method} {this.attrs.error.options.url}
<br />
<br />
{responseText}

View File

@@ -1,9 +1,10 @@
import Component from '../Component';
import icon from '../helpers/icon';
import withAttr from '../utils/withAttr';
/**
* The `Select` component displays a <select> input, surrounded with some extra
* elements for styling. It accepts the following props:
* elements for styling. It accepts the following attrs:
*
* - `options` A map of option values to labels.
* - `onchange` A callback to run when the selected value is changed.
@@ -12,13 +13,13 @@ import icon from '../helpers/icon';
*/
export default class Select extends Component {
view() {
const { options, onchange, value, disabled } = this.props;
const { options, onchange, value, disabled } = this.attrs;
return (
<span className="Select">
<select
className="Select-input FormControl"
onchange={onchange ? m.withAttr('value', onchange.bind(this)) : undefined}
onchange={onchange ? withAttr('value', onchange.bind(this)) : undefined}
value={value}
disabled={disabled}
>

View File

@@ -1,31 +1,49 @@
import Dropdown from './Dropdown';
import icon from '../helpers/icon';
/**
* Determines via a vnode is currently "active".
* Due to changes in Mithril 2, attrs will not be instantiated until AFTER view()
* is initially called on the parent component, so we can not always depend on the
* active attr to determine which element should be displayed as the "active child".
*
* This is a temporary patch, and as so, is not exported / placed in utils.
*/
function isActive(vnode) {
const tag = vnode.tag;
if ('initAttrs' in tag) {
tag.initAttrs(vnode.attrs);
}
return 'isActive' in tag ? tag.isActive(vnode.attrs) : vnode.attrs.active;
}
/**
* The `SelectDropdown` component is the same as a `Dropdown`, except the toggle
* button's label is set as the label of the first child which has a truthy
* `active` prop.
*
* ### Props
* ### Attrs
*
* - `caretIcon`
* - `defaultLabel`
*/
export default class SelectDropdown extends Dropdown {
static initProps(props) {
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-sort';
static initAttrs(attrs) {
attrs.caretIcon = typeof attrs.caretIcon !== 'undefined' ? attrs.caretIcon : 'fas fa-sort';
super.initProps(props);
super.initAttrs(attrs);
props.className += ' Dropdown--select';
attrs.className += ' Dropdown--select';
}
getButtonContent() {
const activeChild = this.props.children.filter((child) => child.props.active)[0];
let label = (activeChild && activeChild.props.children) || this.props.defaultLabel;
getButtonContent(children) {
const activeChild = children.find(isActive);
let label = (activeChild && activeChild.children) || this.attrs.defaultLabel;
if (label instanceof Array) label = label[0];
return [<span className="Button-label">{label}</span>, icon(this.props.caretIcon, { className: 'Button-caret' })];
return [<span className="Button-label">{label}</span>, icon(this.attrs.caretIcon, { className: 'Button-caret' })];
}
}

View File

@@ -7,25 +7,25 @@ import icon from '../helpers/icon';
* is displayed as its own button prior to the toggle button.
*/
export default class SplitDropdown extends Dropdown {
static initProps(props) {
super.initProps(props);
static initAttrs(attrs) {
super.initAttrs(attrs);
props.className += ' Dropdown--split';
props.menuClassName += ' Dropdown-menu--right';
attrs.className += ' Dropdown--split';
attrs.menuClassName += ' Dropdown-menu--right';
}
getButton() {
// Make a copy of the props of the first child component. We will assign
// these props to a new button, so that it has exactly the same behaviour as
getButton(children) {
// Make a copy of the attrs of the first child component. We will assign
// these attrs to a new button, so that it has exactly the same behaviour as
// the first child.
const firstChild = this.getFirstChild();
const buttonProps = Object.assign({}, firstChild.props);
buttonProps.className = (buttonProps.className || '') + ' SplitDropdown-button Button ' + this.props.buttonClassName;
const firstChild = this.getFirstChild(children);
const buttonAttrs = Object.assign({}, firstChild.attrs);
buttonAttrs.className = (buttonAttrs.className || '') + ' SplitDropdown-button Button ' + this.attrs.buttonClassName;
return [
Button.component(buttonProps),
<button className={'Dropdown-toggle Button Button--icon ' + this.props.buttonClassName} data-toggle="dropdown">
{icon(this.props.icon, { className: 'Button-icon' })}
Button.component(buttonAttrs, firstChild.children),
<button className={'Dropdown-toggle Button Button--icon ' + this.attrs.buttonClassName} data-toggle="dropdown">
{icon(this.attrs.icon, { className: 'Button-icon' })}
{icon('fas fa-caret-down', { className: 'Button-caret' })}
</button>,
];
@@ -38,8 +38,8 @@ export default class SplitDropdown extends Dropdown {
* @return {*}
* @protected
*/
getFirstChild() {
let firstChild = this.props.children;
getFirstChild(children) {
let firstChild = children;
while (firstChild instanceof Array) firstChild = firstChild[0];

View File

@@ -5,13 +5,13 @@ import Checkbox from './Checkbox';
* a tick/cross one.
*/
export default class Switch extends Checkbox {
static initProps(props) {
super.initProps(props);
static initAttrs(attrs) {
super.initAttrs(attrs);
props.className = (props.className || '') + ' Checkbox--switch';
attrs.className = (attrs.className || '') + ' Checkbox--switch';
}
getDisplay() {
return this.props.loading ? super.getDisplay() : '';
return this.attrs.loading ? super.getDisplay() : '';
}
}

View File

@@ -2,14 +2,14 @@ import Separator from '../components/Separator';
import classList from '../utils/classList';
function isSeparator(item) {
return item && item.component === Separator;
return item.tag === Separator;
}
function withoutUnnecessarySeparators(items) {
const newItems = [];
let prevItem;
items.forEach((item, i) => {
items.filter(Boolean).forEach((item, i) => {
if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) {
prevItem = item;
newItems.push(item);
@@ -30,21 +30,29 @@ export default function listItems(items) {
if (!(items instanceof Array)) items = [items];
return withoutUnnecessarySeparators(items).map((item) => {
const isListItem = item.component && item.component.isListItem;
const active = item.component && item.component.isActive && item.component.isActive(item.props);
const className = item.props ? item.props.itemClassName : item.itemClassName;
const isListItem = item.tag && item.tag.isListItem;
const active = item.tag && item.tag.isActive && item.tag.isActive(item.attrs);
const className = (item.attrs && item.attrs.itemClassName) || item.itemClassName;
if (isListItem) {
item.attrs = item.attrs || {};
item.attrs.key = item.attrs.key || item.itemName;
item.key = item.attrs.key;
}
return isListItem ? (
const node = isListItem ? (
item
) : (
<li className={classList([item.itemName ? 'item-' + item.itemName : '', className, active ? 'active' : ''])} key={item.itemName}>
<li
className={classList([className, item.itemName && `item-${item.itemName}`, active && 'active'])}
key={(item.attrs && item.attrs.key) || item.itemName}
>
{item}
</li>
);
node.state = node.state || {};
return node;
});
}

View File

@@ -13,7 +13,7 @@ export default class AlertManagerState {
/**
* Show an Alert in the alerts area.
*/
show(attrs, componentClass = Alert) {
show(children, attrs, componentClass = Alert) {
// Breaking Change Compliance Warning, Remove in Beta 15.
// This is applied to the first argument (attrs) because previously, the alert was passed as the first argument.
if (attrs === Alert || attrs instanceof Alert) {
@@ -22,7 +22,7 @@ export default class AlertManagerState {
throw new Error('The AlertManager can only show Alerts. Whichever extension triggered this alert should be updated to comply with beta 14.');
}
// End Change Compliance Warning, Remove in Beta 15
this.activeAlerts[++this.alertId] = { attrs, componentClass };
this.activeAlerts[++this.alertId] = { children, attrs, componentClass };
m.redraw();
return this.alertId;

View File

@@ -32,7 +32,7 @@ export default class ModalManagerState {
this.modal = { componentClass, attrs };
m.redraw(true);
m.redraw.sync();
}
/**
@@ -50,7 +50,7 @@ export default class ModalManagerState {
// ahead.
this.closeTimeout = setTimeout(() => {
this.modal = null;
m.lazyRedraw();
m.redraw();
});
}
}

View File

@@ -1,20 +1,25 @@
/**
* The `SubtreeRetainer` class represents a Mithril virtual DOM subtree. It
* keeps track of a number of pieces of data, allowing the subtree to be
* retained if none of them have changed.
* The `SubtreeRetainer` class keeps track of a number of pieces of data,
* comparing the values of these pieces at every iteration.
*
* This is useful for preventing redraws to relatively static (or huge)
* components whose VDOM only depends on very few values, when none of them
* have changed.
*
* @example
* // constructor
* // Check two callbacks for changes on each update
* this.subtree = new SubtreeRetainer(
* () => this.props.post.freshness,
* () => this.attrs.post.freshness,
* () => this.showing
* );
* this.subtree.check(() => this.props.user.freshness);
*
* // view
* this.subtree.retain() || 'expensive expression'
* // Add more callbacks to be checked for updates
* this.subtree.check(() => this.attrs.user.freshness);
*
* @see https://lhorie.github.io/mithril/mithril.html#persisting-dom-elements-across-route-changes
* // In a component's onbeforeupdate() method:
* return this.subtree.needsRebuild()
*
* @see https://mithril.js.org/lifecycle-methods.html#onbeforeupdate
*/
export default class SubtreeRetainer {
/**
@@ -26,13 +31,13 @@ export default class SubtreeRetainer {
}
/**
* Return a virtual DOM directive that will retain a subtree if no data has
* changed since the last check.
* Return whether any data has changed since the last check.
* If so, Mithril needs to re-diff the vnode and its children.
*
* @return {Object|false}
* @return {boolean}
* @public
*/
retain() {
needsRebuild() {
let needsRebuild = false;
this.callbacks.forEach((callback, i) => {
@@ -44,7 +49,7 @@ export default class SubtreeRetainer {
}
});
return needsRebuild ? false : { subtree: 'retain' };
return needsRebuild;
}
/**

View File

@@ -8,7 +8,7 @@ export default function extractText(vdom) {
if (vdom instanceof Array) {
return vdom.map((element) => extractText(element)).join('');
} else if (typeof vdom === 'object' && vdom !== null) {
return extractText(vdom.children);
return vdom.children ? extractText(vdom.children) : vdom.text;
} else {
return vdom;
}

View File

@@ -2,7 +2,7 @@
* The `mapRoutes` utility converts a map of named application routes into a
* format that can be understood by Mithril.
*
* @see https://lhorie.github.io/mithril/mithril.route.html#defining-routes
* @see https://mithril.js.org/route.html#signature
* @param {Object} routes
* @param {String} [basePath]
* @return {Object}
@@ -13,9 +13,11 @@ export default function mapRoutes(routes, basePath = '') {
for (const key in routes) {
const route = routes[key];
if (route.component) route.component.props.routeName = key;
map[basePath + route.path] = route.component;
map[basePath + route.path] = {
render() {
return m(route.component, { routeName: key });
},
};
}
return map;

View File

@@ -1,27 +1,60 @@
import Component from '../Component';
import Stream from 'mithril/stream';
import extract from './extract';
export default function patchMithril(global) {
const mo = global.m;
const defaultMithril = global.m;
const m = function (comp, ...args) {
if (comp.prototype && comp.prototype instanceof Component) {
let children = args.slice(1);
if (children.length === 1 && Array.isArray(children[0])) {
children = children[0];
/**
* If the href URL of the link is the same as the current page path
* we will not add a new entry to the browser history.
*
* This allows us to still refresh the Page component
* without adding endless history entries.
*
* We also add the `force` attribute that adds a custom state key
* for when you want to force a complete refresh of the Page
*/
const defaultLinkView = defaultMithril.route.Link.view;
const modifiedLink = {
view: function (vnode) {
let { href, options = {} } = vnode.attrs;
if (href === m.route.get()) {
if (!('replace' in options)) options.replace = true;
}
return comp.component(args[0], children);
}
if (extract(vnode.attrs, 'force')) {
if (!('state' in options)) options.state = {};
if (!('key' in options.state)) options.state.key = Date.now();
}
const node = mo.apply(this, arguments);
vnode.attrs.options = options;
return defaultLinkView(vnode);
},
};
const modifiedMithril = function (comp, ...args) {
const node = defaultMithril.apply(this, arguments);
if (!node.attrs) node.attrs = {};
// Allows the use of the bidi attr.
if (node.attrs.bidi) {
m.bidi(node, node.attrs.bidi);
modifiedMithril.bidi(node, node.attrs.bidi);
}
// Allows us to use a "route" attr on links, which will automatically convert the link to one which
// supports linking to other pages in the SPA without refreshing the document.
if (node.attrs.route) {
node.attrs.href = node.attrs.route;
node.attrs.config = m.route;
node.tag = modifiedLink;
// For some reason, m.route.Link does not like vnode.text, so if present, we
// need to convert it to text vnodes and store it in children.
if (node.text) {
node.children = { tag: '#', children: node.text };
}
delete node.attrs.route;
}
@@ -29,17 +62,11 @@ export default function patchMithril(global) {
return node;
};
Object.keys(mo).forEach((key) => (m[key] = mo[key]));
Object.keys(defaultMithril).forEach((key) => (modifiedMithril[key] = defaultMithril[key]));
/**
* Redraw only if not in the middle of a computation (e.g. a route change).
*
* @return {void}
*/
m.lazyRedraw = function () {
m.startComputation();
m.endComputation();
};
modifiedMithril.stream = Stream;
global.m = m;
modifiedMithril.route.Link = modifiedLink;
global.m = modifiedMithril;
}

View File

@@ -0,0 +1,15 @@
import Mithril from 'mithril';
/**
* Mithril 2 does not completely rerender the page if a route change leads to the same route
* (or the same component handling a different route). This util calls m.route.set, forcing a reonit.
*
* @see https://mithril.js.org/route.html#key-parameter
*/
export default function setRouteWithForcedRefresh(route: string, params = null, options: Mithril.RouteOptions = {}) {
const newOptions = { ...options };
newOptions.state = newOptions.state || {};
newOptions.state.key = Date.now();
m.route.set(route, params, newOptions);
}

View File

@@ -0,0 +1,15 @@
/**
* An event handler factory that makes it simpler to implement data binding
* for component event listeners.
*
* The handler created by this factory passes the DOM element's attribute
* identified by the first argument to the callback (usually a bidirectional
* Mithril stream: https://mithril.js.org/stream.html#bidirectional-bindings).
*
* Replaces m.withAttr for Mithril 2.0.
* @see https://mithril.js.org/archive/v0.2.5/mithril.withAttr.html
*/
export default (key: string, cb: Function) =>
function (this: Element) {
cb(this.getAttribute(key) || this[key]);
};

View File

@@ -115,15 +115,15 @@ export default class ForumApplication extends Application {
this.routes[defaultAction].path = '/';
this.history.push(defaultAction, this.translator.trans('core.forum.header.back_to_index_tooltip'), '/');
m.mount(document.getElementById('app-navigation'), Navigation.component({ className: 'App-backControl', drawer: true }));
m.mount(document.getElementById('header-navigation'), Navigation.component());
m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component());
m.mount(document.getElementById('composer'), Composer.component({ state: this.composer }));
m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
m.mount(document.getElementById('header-navigation'), Navigation);
m.mount(document.getElementById('header-primary'), HeaderPrimary);
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
m.mount(document.getElementById('composer'), { view: () => Composer.component({ state: this.composer }) });
this.pane = new Pane(document.getElementById('app'));
m.route.mode = 'pathname';
m.route.prefix = '';
super.mount(this.forum.attribute('basePath'));
alertEmailConfirmation(this);
@@ -161,8 +161,8 @@ export default class ForumApplication extends Application {
* will be reloaded. Otherwise, a SignUpModal will be opened, prefilled
* with the provided details.
*
* @param {Object} payload A dictionary of props to pass into the sign up
* modal. A truthy `loggedIn` prop indicates that the user has logged
* @param {Object} payload A dictionary of attrs to pass into the sign up
* modal. A truthy `loggedIn` attr indicates that the user has logged
* in, and thus the page is reloaded.
* @public
*/

View File

@@ -3,7 +3,6 @@ import compat from '../common/compat';
import PostControls from './utils/PostControls';
import KeyboardNavigatable from './utils/KeyboardNavigatable';
import slidable from './utils/slidable';
import affixSidebar from './utils/affixSidebar';
import History from './utils/History';
import DiscussionControls from './utils/DiscussionControls';
import alertEmailConfirmation from './utils/alertEmailConfirmation';
@@ -15,6 +14,7 @@ import GlobalSearchState from './states/GlobalSearchState';
import NotificationListState from './states/NotificationListState';
import PostStreamState from './states/PostStreamState';
import SearchState from './states/SearchState';
import AffixedSidebar from './components/AffixedSidebar';
import DiscussionPage from './components/DiscussionPage';
import LogInModal from './components/LogInModal';
import ComposerBody from './components/ComposerBody';
@@ -61,6 +61,7 @@ import NotificationList from './components/NotificationList';
import WelcomeHero from './components/WelcomeHero';
import SignUpModal from './components/SignUpModal';
import CommentPost from './components/CommentPost';
import ComposerPostPreview from './components/ComposerPostPreview';
import ReplyComposer from './components/ReplyComposer';
import NotificationsPage from './components/NotificationsPage';
import PostStreamScrubber from './components/PostStreamScrubber';
@@ -77,7 +78,6 @@ export default Object.assign(compat, {
'utils/PostControls': PostControls,
'utils/KeyboardNavigatable': KeyboardNavigatable,
'utils/slidable': slidable,
'utils/affixSidebar': affixSidebar,
'utils/History': History,
'utils/DiscussionControls': DiscussionControls,
'utils/alertEmailConfirmation': alertEmailConfirmation,
@@ -89,6 +89,7 @@ export default Object.assign(compat, {
'states/NotificationListState': NotificationListState,
'states/PostStreamState': PostStreamState,
'states/SearchState': SearchState,
'components/AffixedSidebar': AffixedSidebar,
'components/DiscussionPage': DiscussionPage,
'components/LogInModal': LogInModal,
'components/ComposerBody': ComposerBody,
@@ -135,6 +136,7 @@ export default Object.assign(compat, {
'components/WelcomeHero': WelcomeHero,
'components/SignUpModal': SignUpModal,
'components/CommentPost': CommentPost,
'components/ComposerPostPreview': ComposerPostPreview,
'components/ReplyComposer': ReplyComposer,
'components/NotificationsPage': NotificationsPage,
'components/PostStreamScrubber': PostStreamScrubber,

View File

@@ -0,0 +1,51 @@
import Component from '../../common/Component';
/**
* The `AffixedSidebar` component uses Bootstrap's "affix" plugin to keep a
* sidebar navigation at the top of the viewport when scrolling.
*
* ### Children
*
* The component must wrap an element that itself wraps an <ul> element, which
* will be "affixed".
*
* @see https://getbootstrap.com/docs/3.4/javascript/#affix
*/
export default class AffixedSidebar extends Component {
view(vnode) {
return vnode.children[0];
}
oncreate(vnode) {
super.oncreate(vnode);
// Register the affix plugin to execute on every window resize (and trigger)
this.boundOnresize = this.onresize.bind(this);
$(window).on('resize', this.boundOnresize).resize();
}
onremove() {
$(window).off('resize', this.boundOnresize);
}
onresize() {
const $sidebar = this.$();
const $header = $('#header');
const $footer = $('#footer');
const $affixElement = $sidebar.find('> ul');
$(window).off('.affix');
$affixElement.removeClass('affix affix-top affix-bottom').removeData('bs.affix');
// Don't affix the sidebar if it is taller than the viewport (otherwise
// there would be no way to scroll through its content).
if ($sidebar.outerHeight(true) > $(window).height() - $header.outerHeight(true)) return;
$affixElement.affix({
offset: {
top: () => $sidebar.offset().top - $header.outerHeight(true) - parseInt($sidebar.css('margin-top'), 10),
bottom: () => (this.bottom = $footer.outerHeight(true)),
},
});
}
}

View File

@@ -3,6 +3,7 @@ import avatar from '../../common/helpers/avatar';
import icon from '../../common/helpers/icon';
import listItems from '../../common/helpers/listItems';
import ItemList from '../../common/utils/ItemList';
import classList from '../../common/utils/classList';
import Button from '../../common/components/Button';
import LoadingIndicator from '../../common/components/LoadingIndicator';
@@ -10,13 +11,15 @@ import LoadingIndicator from '../../common/components/LoadingIndicator';
* The `AvatarEditor` component displays a user's avatar along with a dropdown
* menu which allows the user to upload/remove the avatar.
*
* ### Props
* ### Attrs
*
* - `className`
* - `user`
*/
export default class AvatarEditor extends Component {
init() {
oninit(vnode) {
super.oninit(vnode);
/**
* Whether or not an avatar upload is in progress.
*
@@ -32,17 +35,11 @@ export default class AvatarEditor extends Component {
this.isDraggedOver = false;
}
static initProps(props) {
super.initProps(props);
props.className = props.className || '';
}
view() {
const user = this.props.user;
const user = this.attrs.user;
return (
<div className={'AvatarEditor Dropdown ' + this.props.className + (this.loading ? ' loading' : '') + (this.isDraggedOver ? ' dragover' : '')}>
<div className={classList(['AvatarEditor', 'Dropdown', this.attrs.className, this.loading && 'loading', this.isDraggedOver && 'dragover'])}>
{avatar(user)}
<a
className={user.avatarUrl() ? 'Dropdown-toggle' : 'Dropdown-toggle AvatarEditor--noAvatar'}
@@ -55,7 +52,7 @@ export default class AvatarEditor extends Component {
ondragend={this.disableDragover.bind(this)}
ondrop={this.dropUpload.bind(this)}
>
{this.loading ? LoadingIndicator.component() : user.avatarUrl() ? icon('fas fa-pencil-alt') : icon('fas fa-plus-circle')}
{this.loading ? <LoadingIndicator /> : user.avatarUrl() ? icon('fas fa-pencil-alt') : icon('fas fa-plus-circle')}
</a>
<ul className="Dropdown-menu Menu">{listItems(this.controlItems().toArray())}</ul>
</div>
@@ -72,20 +69,16 @@ export default class AvatarEditor extends Component {
items.add(
'upload',
Button.component({
icon: 'fas fa-upload',
children: app.translator.trans('core.forum.user.avatar_upload_button'),
onclick: this.openPicker.bind(this),
})
<Button icon="fas fa-upload" onclick={this.openPicker.bind(this)}>
{app.translator.trans('core.forum.user.avatar_upload_button')}
</Button>
);
items.add(
'remove',
Button.component({
icon: 'fas fa-times',
children: app.translator.trans('core.forum.user.avatar_remove_button'),
onclick: this.remove.bind(this),
})
<Button icon="fas fa-times" onclick={this.remove.bind(this)}>
{app.translator.trans('core.forum.user.avatar_remove_button')}
</Button>
);
return items;
@@ -134,7 +127,7 @@ export default class AvatarEditor extends Component {
* @param {Event} e
*/
quickUpload(e) {
if (!this.props.user.avatarUrl()) {
if (!this.attrs.user.avatarUrl()) {
e.preventDefault();
e.stopPropagation();
this.openPicker();
@@ -149,7 +142,6 @@ export default class AvatarEditor extends Component {
// Create a hidden HTML input element and click on it so the user can select
// an avatar file. Once they have, we will upload it via the API.
const user = this.props.user;
const $input = $('<input type="file">');
$input
@@ -169,7 +161,7 @@ export default class AvatarEditor extends Component {
upload(file) {
if (this.loading) return;
const user = this.props.user;
const user = this.attrs.user;
const data = new FormData();
data.append('avatar', file);
@@ -179,9 +171,9 @@ export default class AvatarEditor extends Component {
app
.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar',
url: `${app.forum.attribute('apiUrl')}/users/${user.id()}/avatar`,
serialize: (raw) => raw,
data,
body: data,
})
.then(this.success.bind(this), this.failure.bind(this));
}
@@ -190,7 +182,7 @@ export default class AvatarEditor extends Component {
* Remove the user's avatar.
*/
remove() {
const user = this.props.user;
const user = this.attrs.user;
this.loading = true;
m.redraw();
@@ -198,7 +190,7 @@ export default class AvatarEditor extends Component {
app
.request({
method: 'DELETE',
url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar',
url: `${app.forum.attribute('apiUrl')}/users/${user.id()}/avatar`,
})
.then(this.success.bind(this), this.failure.bind(this));
}
@@ -212,7 +204,7 @@ export default class AvatarEditor extends Component {
*/
success(response) {
app.store.pushPayload(response);
delete this.props.user.avatarColor;
delete this.attrs.user.avatarColor;
this.loading = false;
m.redraw();

View File

@@ -6,8 +6,8 @@ import Button from '../../common/components/Button';
* to change their email address.
*/
export default class ChangeEmailModal extends Modal {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
/**
* Whether or not the email has been changed successfully.
@@ -21,14 +21,14 @@ export default class ChangeEmailModal extends Modal {
*
* @type {function}
*/
this.email = m.prop(app.session.user.email());
this.email = m.stream(app.session.user.email());
/**
* The value of the password input.
*
* @type {function}
*/
this.password = m.prop('');
this.password = m.stream('');
}
className() {
@@ -81,12 +81,14 @@ export default class ChangeEmailModal extends Modal {
/>
</div>
<div className="Form-group">
{Button.component({
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
children: app.translator.trans('core.forum.change_email.submit_button'),
})}
{Button.component(
{
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
},
app.translator.trans('core.forum.change_email.submit_button')
)}
</div>
</div>
</div>
@@ -122,7 +124,7 @@ export default class ChangeEmailModal extends Modal {
onerror(error) {
if (error.status === 401) {
error.alert.children = app.translator.trans('core.forum.change_email.incorrect_password_message');
error.alert.content = app.translator.trans('core.forum.change_email.incorrect_password_message');
}
super.onerror(error);

View File

@@ -20,12 +20,14 @@ export default class ChangePasswordModal extends Modal {
<div className="Form Form--centered">
<p className="helpText">{app.translator.trans('core.forum.change_password.text')}</p>
<div className="Form-group">
{Button.component({
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
children: app.translator.trans('core.forum.change_password.send_button'),
})}
{Button.component(
{
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
},
app.translator.trans('core.forum.change_password.send_button')
)}
</div>
</div>
</div>
@@ -41,7 +43,7 @@ export default class ChangePasswordModal extends Modal {
.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/forgot',
data: { email: app.session.user.email() },
body: { email: app.session.user.email() },
})
.then(this.hide.bind(this), this.loaded.bind(this));
}

View File

@@ -1,5 +1,3 @@
/*global s9e, hljs*/
import Post from './Post';
import classList from '../../common/utils/classList';
import PostUser from './PostUser';
@@ -9,19 +7,20 @@ import EditPostComposer from './EditPostComposer';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
import Button from '../../common/components/Button';
import ComposerPostPreview from './ComposerPostPreview';
/**
* The `CommentPost` component displays a standard `comment`-typed post. This
* includes a number of item lists (controls, header, and footer) surrounding
* the post's HTML content.
*
* ### Props
* ### Attrs
*
* - `post`
*/
export default class CommentPost extends Post {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
/**
* If the post has been hidden, then this flag determines whether or not its
@@ -41,48 +40,46 @@ export default class CommentPost extends Post {
this.subtree.check(
() => this.cardVisible,
() => this.isEditing()
() => this.isEditing(),
() => this.revealContent
);
}
content() {
// Note: we avoid using JSX for the <ul> below because it results in some
// weirdness in Mithril.js 0.1.x (see flarum/core#975). This workaround can
// be reverted when we upgrade to Mithril 1.0.
return super
.content()
.concat([
<header className="Post-header">{m('ul', listItems(this.headerItems().toArray()))}</header>,
<div className="Post-body">
{this.isEditing() ? <div className="Post-preview" config={this.configPreview.bind(this)} /> : m.trust(this.props.post.contentHtml())}
</div>,
]);
return super.content().concat([
<header className="Post-header">
<ul>{listItems(this.headerItems().toArray())}</ul>
</header>,
<div className="Post-body">
{this.isEditing() ? <ComposerPostPreview className="Post-preview" composer={app.composer} /> : m.trust(this.attrs.post.contentHtml())}
</div>,
]);
}
config(isInitialized, context) {
super.config(...arguments);
onupdate(vnode) {
super.onupdate();
const contentHtml = this.isEditing() ? '' : this.props.post.contentHtml();
const contentHtml = this.isEditing() ? '' : this.attrs.post.contentHtml();
// If the post content has changed since the last render, we'll run through
// all of the <script> tags in the content and evaluate them. This is
// necessary because TextFormatter outputs them for e.g. syntax highlighting.
if (context.contentHtml !== contentHtml) {
if (this.contentHtml !== contentHtml) {
this.$('.Post-body script').each(function () {
eval.call(window, $(this).text());
});
}
context.contentHtml = contentHtml;
this.contentHtml = contentHtml;
}
isEditing() {
return app.composer.bodyMatches(EditPostComposer, { post: this.props.post });
return app.composer.bodyMatches(EditPostComposer, { post: this.attrs.post });
}
attrs() {
const post = this.props.post;
const attrs = super.attrs();
elementAttrs() {
const post = this.attrs.post;
const attrs = super.elementAttrs();
attrs.className =
(attrs.className || '') +
@@ -98,27 +95,6 @@ export default class CommentPost extends Post {
return attrs;
}
configPreview(element, isInitialized, context) {
if (isInitialized) return;
// Every 50ms, if the composer content has changed, then update the post's
// body with a preview.
let preview;
const updatePreview = () => {
const content = app.composer.fields.content();
if (preview === content) return;
preview = content;
s9e.TextFormatter.preview(preview || '', element);
};
updatePreview();
const updateInterval = setInterval(updatePreview, 50);
context.onunload = () => clearInterval(updateInterval);
}
/**
* Toggle the visibility of a hidden post's content.
*/
@@ -133,7 +109,7 @@ export default class CommentPost extends Post {
*/
headerItems() {
const items = new ItemList();
const post = this.props.post;
const post = this.attrs.post;
items.add(
'user',

View File

@@ -11,13 +11,15 @@ import ComposerState from '../states/ComposerState';
* `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`.
*/
export default class Composer extends Component {
init() {
oninit(vnode) {
super.oninit(vnode);
/**
* The composer's "state".
*
* @type {ComposerState}
*/
this.state = this.props.state;
this.state = this.attrs.state;
/**
* Whether or not the composer currently has focus.
@@ -45,7 +47,7 @@ export default class Composer extends Component {
return (
<div className={'Composer ' + classList(classes)}>
<div className="Composer-handle" config={this.configHandle.bind(this)} />
<div className="Composer-handle" oncreate={this.configHandle.bind(this)} />
<ul className="Composer-controls">{listItems(this.controlItems().toArray())}</ul>
<div className="Composer-content" onclick={showIfMinimized}>
{body.componentClass ? body.componentClass.component({ ...body.attrs, composer: this.state, disabled: classes.minimized }) : ''}
@@ -54,7 +56,7 @@ export default class Composer extends Component {
);
}
config(isInitialized, context) {
onupdate() {
if (this.state.position === this.prevPosition) {
// Set the height of the Composer element and its contents on each redraw,
// so that they do not lose it if their DOM elements are recreated.
@@ -64,12 +66,10 @@ export default class Composer extends Component {
this.prevPosition = this.state.position;
}
}
if (isInitialized) return;
// Since this component is a part of the global UI that persists between
// routes, we will flag the DOM to be retained across route changes.
context.retain = true;
oncreate(vnode) {
super.oncreate(vnode);
this.initializeHeight();
this.$().hide().css('bottom', -this.state.computedHeight());
@@ -82,38 +82,33 @@ export default class Composer extends Component {
});
// When the escape key is pressed on any inputs, close the composer.
this.$().on('keydown', ':input', 'esc', () => this.state.close());
this.$().on('keydown', ':input', 'esc', () => this.close());
const handlers = {};
this.handlers = {};
$(window)
.on('resize', (handlers.onresize = this.updateHeight.bind(this)))
.on('resize', (this.handlers.onresize = this.updateHeight.bind(this)))
.resize();
$(document)
.on('mousemove', (handlers.onmousemove = this.onmousemove.bind(this)))
.on('mouseup', (handlers.onmouseup = this.onmouseup.bind(this)));
.on('mousemove', (this.handlers.onmousemove = this.onmousemove.bind(this)))
.on('mouseup', (this.handlers.onmouseup = this.onmouseup.bind(this)));
}
context.onunload = () => {
$(window).off('resize', handlers.onresize);
onremove() {
$(window).off('resize', this.handlers.onresize);
$(document).off('mousemove', handlers.onmousemove).off('mouseup', handlers.onmouseup);
};
$(document).off('mousemove', this.handlers.onmousemove).off('mouseup', this.handlers.onmouseup);
}
/**
* Add the necessary event handlers to the composer's handle so that it can
* be used to resize the composer.
*
* @param {DOMElement} element
* @param {Boolean} isInitialized
*/
configHandle(element, isInitialized) {
if (isInitialized) return;
configHandle(vnode) {
const composer = this;
$(element)
$(vnode.dom)
.css('cursor', 'row-resize')
.bind('dragstart mousedown', (e) => e.preventDefault())
.mousedown(function (e) {

View File

@@ -11,7 +11,7 @@ import ItemList from '../../common/utils/ItemList';
* composer. Subclasses should implement the `onsubmit` method and override
* `headerTimes`.
*
* ### Props
* ### Attrs
*
* - `composer`
* - `originalContent`
@@ -24,8 +24,10 @@ import ItemList from '../../common/utils/ItemList';
* @abstract
*/
export default class ComposerBody extends Component {
init() {
this.composer = this.props.composer;
oninit(vnode) {
super.oninit(vnode);
this.composer = this.attrs.composer;
/**
* Whether or not the component is loading.
@@ -37,11 +39,11 @@ export default class ComposerBody extends Component {
// Let the composer state know to ask for confirmation under certain
// circumstances, if the body supports / requires it and has a corresponding
// confirmation question to ask.
if (this.props.confirmExit) {
this.composer.preventClosingWhen(() => this.hasChanges(), this.props.confirmExit);
if (this.attrs.confirmExit) {
this.composer.preventClosingWhen(() => this.hasChanges(), this.attrs.confirmExit);
}
this.composer.fields.content(this.props.originalContent || '');
this.composer.fields.content(this.attrs.originalContent || '');
/**
* @deprecated BC layer, remove in Beta 15.
@@ -53,15 +55,15 @@ export default class ComposerBody extends Component {
view() {
return (
<ConfirmDocumentUnload when={this.hasChanges.bind(this)}>
<div className={'ComposerBody ' + (this.props.className || '')}>
{avatar(this.props.user, { className: 'ComposerBody-avatar' })}
<div className={'ComposerBody ' + (this.attrs.className || '')}>
{avatar(this.attrs.user, { className: 'ComposerBody-avatar' })}
<div className="ComposerBody-content">
<ul className="ComposerBody-header">{listItems(this.headerItems().toArray())}</ul>
<div className="ComposerBody-editor">
{TextEditor.component({
submitLabel: this.props.submitLabel,
placeholder: this.props.placeholder,
disabled: this.loading || this.props.disabled,
submitLabel: this.attrs.submitLabel,
placeholder: this.attrs.placeholder,
disabled: this.loading || this.attrs.disabled,
composer: this.composer,
preview: this.jumpToPreview && this.jumpToPreview.bind(this),
onchange: this.composer.fields.content,
@@ -84,7 +86,7 @@ export default class ComposerBody extends Component {
hasChanges() {
const content = this.composer.fields.content();
return content && content !== this.props.originalContent;
return content && content !== this.attrs.originalContent;
}
/**

View File

@@ -5,9 +5,9 @@ import Button from '../../common/components/Button';
* controls.
*/
export default class ComposerButton extends Button {
static initProps(props) {
super.initProps(props);
static initAttrs(attrs) {
super.initAttrs(attrs);
props.className = props.className || 'Button Button--icon Button--link';
attrs.className = attrs.className || 'Button Button--icon Button--link';
}
}

View File

@@ -0,0 +1,54 @@
/*global s9e*/
import Component from '../../common/Component';
/**
* The `ComposerPostPreview` component renders Markdown as HTML using the
* TextFormatter library, polling a data source for changes every 50ms. This is
* done to prevent expensive redraws on e.g. every single keystroke, while
* still retaining the perception of live updates for the user.
*
* ### Attrs
*
* - `composer` The state of the composer controlling this preview.
* - `className` A CSS class for the element surrounding the preview.
* - `surround` A callback that can execute code before and after re-render, e.g. for scroll anchoring.
*/
export default class ComposerPostPreview extends Component {
static initAttrs(attrs) {
attrs.className = attrs.className || '';
attrs.surround = attrs.surround || ((preview) => preview());
}
view() {
return <div className={this.attrs.className} />;
}
oncreate(vnode) {
super.oncreate(vnode);
// Every 50ms, if the composer content has changed, then update the post's
// body with a preview.
let preview;
const updatePreview = () => {
// Since we're polling, the composer may have been closed in the meantime,
// so we bail in that case.
if (!this.attrs.composer.isVisible()) return;
const content = this.attrs.composer.fields.content();
if (preview === content) return;
preview = content;
this.attrs.surround(() => s9e.TextFormatter.preview(preview || '', vnode.dom));
};
updatePreview();
this.updateInterval = setInterval(updatePreview, 50);
}
onremove() {
clearInterval(this.updateInterval);
}
}

View File

@@ -7,16 +7,26 @@ import extractText from '../../common/utils/extractText';
* enter the title of their discussion. It also overrides the `submit` and
* `willExit` actions to account for the title.
*
* ### Props
* ### Attrs
*
* - All of the props for ComposerBody
* - All of the attrs for ComposerBody
* - `titlePlaceholder`
*/
export default class DiscussionComposer extends ComposerBody {
init() {
super.init();
static initAttrs(attrs) {
super.initAttrs(attrs);
this.composer.fields.title = this.composer.fields.title || m.prop('');
attrs.placeholder = attrs.placeholder || extractText(app.translator.trans('core.forum.composer_discussion.body_placeholder'));
attrs.submitLabel = attrs.submitLabel || app.translator.trans('core.forum.composer_discussion.submit_button');
attrs.confirmExit = attrs.confirmExit || extractText(app.translator.trans('core.forum.composer_discussion.discard_confirmation'));
attrs.titlePlaceholder = attrs.titlePlaceholder || extractText(app.translator.trans('core.forum.composer_discussion.title_placeholder'));
attrs.className = 'ComposerBody--discussion';
}
oninit(vnode) {
super.oninit(vnode);
this.composer.fields.title = this.composer.fields.title || m.stream('');
/**
* The value of the title input.
@@ -26,16 +36,6 @@ export default class DiscussionComposer extends ComposerBody {
this.title = this.composer.fields.title;
}
static initProps(props) {
super.initProps(props);
props.placeholder = props.placeholder || extractText(app.translator.trans('core.forum.composer_discussion.body_placeholder'));
props.submitLabel = props.submitLabel || app.translator.trans('core.forum.composer_discussion.submit_button');
props.confirmExit = props.confirmExit || extractText(app.translator.trans('core.forum.composer_discussion.discard_confirmation'));
props.titlePlaceholder = props.titlePlaceholder || extractText(app.translator.trans('core.forum.composer_discussion.title_placeholder'));
props.className = 'ComposerBody--discussion';
}
headerItems() {
const items = super.headerItems();
@@ -46,10 +46,9 @@ export default class DiscussionComposer extends ComposerBody {
<h3>
<input
className="FormControl"
value={this.title()}
oninput={m.withAttr('value', this.title)}
placeholder={this.props.titlePlaceholder}
disabled={!!this.props.disabled}
bidi={this.title}
placeholder={this.attrs.titlePlaceholder}
disabled={!!this.attrs.disabled}
onkeydown={this.onkeydown.bind(this)}
/>
</h3>
@@ -71,7 +70,7 @@ export default class DiscussionComposer extends ComposerBody {
this.composer.editor.moveCursorTo(0);
}
m.redraw.strategy('none');
e.redraw = false;
}
hasChanges() {
@@ -101,7 +100,7 @@ export default class DiscussionComposer extends ComposerBody {
.then((discussion) => {
this.composer.hide();
app.discussions.refresh();
m.route(app.route.discussion(discussion));
m.route.set(app.route.discussion(discussion));
}, this.loaded.bind(this));
}
}

View File

@@ -5,7 +5,7 @@ import listItems from '../../common/helpers/listItems';
/**
* The `DiscussionHero` component displays the hero on a discussion page.
*
* ### Props
* ### attrs
*
* - `discussion`
*/
@@ -27,7 +27,7 @@ export default class DiscussionHero extends Component {
*/
items() {
const items = new ItemList();
const discussion = this.props.discussion;
const discussion = this.attrs.discussion;
const badges = discussion.badges().toArray();
if (badges.length) {

View File

@@ -7,17 +7,13 @@ import Placeholder from '../../common/components/Placeholder';
/**
* The `DiscussionList` component displays a list of discussions.
*
* ### Props
* ### Attrs
*
* - `state` A DiscussionListState object that represents the discussion lists's state.
*/
export default class DiscussionList extends Component {
init() {
this.state = this.props.state;
}
view() {
const state = this.state;
const state = this.attrs.state;
const params = state.getParams();
let loading;
@@ -25,11 +21,13 @@ export default class DiscussionList extends Component {
if (state.isLoading()) {
loading = LoadingIndicator.component();
} else if (state.moreResults) {
loading = Button.component({
children: app.translator.trans('core.forum.discussion_list.load_more_button'),
className: 'Button',
onclick: state.loadMore.bind(state),
});
loading = Button.component(
{
className: 'Button',
onclick: state.loadMore.bind(state),
},
app.translator.trans('core.forum.discussion_list.load_more_button')
);
}
if (state.empty()) {

View File

@@ -8,7 +8,6 @@ import ItemList from '../../common/utils/ItemList';
import abbreviateNumber from '../../common/utils/abbreviateNumber';
import Dropdown from '../../common/components/Dropdown';
import TerminalPost from './TerminalPost';
import PostPreview from './PostPreview';
import SubtreeRetainer from '../../common/utils/SubtreeRetainer';
import DiscussionControls from '../utils/DiscussionControls';
import slidable from '../utils/slidable';
@@ -20,13 +19,15 @@ import { escapeRegExp } from 'lodash-es';
* The `DiscussionListItem` component shows a single discussion in the
* discussion list.
*
* ### Props
* ### Attrs
*
* - `discussion`
* - `params`
*/
export default class DiscussionListItem extends Component {
init() {
oninit(vnode) {
super.oninit(vnode);
/**
* Set up a subtree retainer so that the discussion will not be redrawn
* unless new data comes in.
@@ -34,7 +35,7 @@ export default class DiscussionListItem extends Component {
* @type {SubtreeRetainer}
*/
this.subtree = new SubtreeRetainer(
() => this.props.discussion.freshness,
() => this.attrs.discussion.freshness,
() => {
const time = app.session.user && app.session.user.markedAllAsReadAt();
return time && time.getTime();
@@ -43,37 +44,33 @@ export default class DiscussionListItem extends Component {
);
}
attrs() {
elementAttrs() {
return {
className: classList([
'DiscussionListItem',
this.active() ? 'active' : '',
this.props.discussion.isHidden() ? 'DiscussionListItem--hidden' : '',
this.attrs.discussion.isHidden() ? 'DiscussionListItem--hidden' : '',
]),
};
}
view() {
const retain = this.subtree.retain();
if (retain) return retain;
const discussion = this.props.discussion;
const discussion = this.attrs.discussion;
const user = discussion.user();
const isUnread = discussion.isUnread();
const isRead = discussion.isRead();
const showUnread = !this.showRepliesCount() && isUnread;
let jumpTo = 0;
const controls = DiscussionControls.controls(discussion, this).toArray();
const attrs = this.attrs();
const attrs = this.elementAttrs();
if (this.props.params.q) {
if (this.attrs.params.q) {
const post = discussion.mostRelevantPost();
if (post) {
jumpTo = post.number();
}
const phrase = escapeRegExp(this.props.params.q);
const phrase = escapeRegExp(this.attrs.params.q);
this.highlightRegExp = new RegExp(phrase + '|' + phrase.trim().replace(/\s+/g, '|'), 'gi');
} else {
jumpTo = Math.min(discussion.lastPostNumber(), (discussion.lastReadPostNumber() || 0) + 1);
@@ -82,12 +79,14 @@ export default class DiscussionListItem extends Component {
return (
<div {...attrs}>
{controls.length
? Dropdown.component({
icon: 'fas fa-ellipsis-v',
children: controls,
className: 'DiscussionListItem-controls',
buttonClassName: 'Button Button--icon Button--flat Slidable-underneath Slidable-underneath--right',
})
? Dropdown.component(
{
icon: 'fas fa-ellipsis-v',
className: 'DiscussionListItem-controls',
buttonClassName: 'Button Button--icon Button--flat Slidable-underneath Slidable-underneath--right',
},
controls
)
: ''}
<a
@@ -99,14 +98,13 @@ export default class DiscussionListItem extends Component {
<div className={'DiscussionListItem-content Slidable-content' + (isUnread ? ' unread' : '') + (isRead ? ' read' : '')}>
<a
href={user ? app.route.user(user) : '#'}
route={user ? app.route.user(user) : '#'}
className="DiscussionListItem-author"
title={extractText(
app.translator.trans('core.forum.discussion_list.started_text', { user: user, ago: humanTime(discussion.createdAt()) })
)}
config={function (element) {
$(element).tooltip({ placement: 'right' });
m.route.apply(this, arguments);
oncreate={function (vnode) {
$(vnode.dom).tooltip({ placement: 'right' });
}}
>
{avatar(user, { title: '' })}
@@ -114,7 +112,7 @@ export default class DiscussionListItem extends Component {
<ul className="DiscussionListItem-badges badges">{listItems(discussion.badges().toArray())}</ul>
<a href={app.route.discussion(discussion, jumpTo)} config={m.route} className="DiscussionListItem-main">
<a route={app.route.discussion(discussion, jumpTo)} className="DiscussionListItem-main">
<h3 className="DiscussionListItem-title">{highlight(discussion.title(), this.highlightRegExp)}</h3>
<ul className="DiscussionListItem-info">{listItems(this.infoItems().toArray())}</ul>
</a>
@@ -131,8 +129,8 @@ export default class DiscussionListItem extends Component {
);
}
config(isInitialized) {
if (isInitialized) return;
oncreate(vnode) {
super.oncreate(vnode);
// If we're on a touch device, set up the discussion row to be slidable.
// This allows the user to drag the row to either side of the screen to
@@ -144,6 +142,12 @@ export default class DiscussionListItem extends Component {
}
}
onbeforeupdate(vnode, old) {
super.onbeforeupdate(vnode, old);
return this.subtree.needsRebuild();
}
/**
* Determine whether or not the discussion is currently being viewed.
*
@@ -152,7 +156,7 @@ export default class DiscussionListItem extends Component {
active() {
const idParam = m.route.param('id');
return idParam && idParam.split('-')[0] === this.props.discussion.id();
return idParam && idParam.split('-')[0] === this.attrs.discussion.id();
}
/**
@@ -163,7 +167,7 @@ export default class DiscussionListItem extends Component {
* @return {Boolean}
*/
showFirstPost() {
return ['newest', 'oldest'].indexOf(this.props.params.sort) !== -1;
return ['newest', 'oldest'].indexOf(this.attrs.params.sort) !== -1;
}
/**
@@ -173,14 +177,14 @@ export default class DiscussionListItem extends Component {
* @return {Boolean}
*/
showRepliesCount() {
return this.props.params.sort === 'replies';
return this.attrs.params.sort === 'replies';
}
/**
* Mark the discussion as read.
*/
markAsRead() {
const discussion = this.props.discussion;
const discussion = this.attrs.discussion;
if (discussion.isUnread()) {
discussion.save({ lastReadPostNumber: discussion.lastPostNumber() });
@@ -197,8 +201,8 @@ export default class DiscussionListItem extends Component {
infoItems() {
const items = new ItemList();
if (this.props.params.q) {
const post = this.props.discussion.mostRelevantPost() || this.props.discussion.firstPost();
if (this.attrs.params.q) {
const post = this.attrs.discussion.mostRelevantPost() || this.attrs.discussion.firstPost();
if (post && post.contentType() === 'comment') {
const excerpt = highlight(post.contentPlain(), this.highlightRegExp, 175);
@@ -208,7 +212,7 @@ export default class DiscussionListItem extends Component {
items.add(
'terminalPost',
TerminalPost.component({
discussion: this.props.discussion,
discussion: this.attrs.discussion,
lastPost: !this.showFirstPost(),
})
);

View File

@@ -0,0 +1,67 @@
import DiscussionList from './DiscussionList';
import Component from '../../common/Component';
const hotEdge = (e) => {
if (e.pageX < 10) app.pane.show();
};
/**
* The `DiscussionListPane` component displays the list of previously viewed
* discussions in a panel that can be displayed by moving the mouse to the left
* edge of the screen, where it can also be pinned in place.
*
* ### Attrs
*
* - `state` A DiscussionListState object that represents the discussion lists's state.
*/
export default class DiscussionListPane extends Component {
view() {
if (!this.attrs.state.hasDiscussions()) {
return;
}
return <div className="DiscussionPage-list">{this.enoughSpace() && <DiscussionList state={this.attrs.state} />}</div>;
}
oncreate(vnode) {
super.oncreate(vnode);
const $list = $(vnode.dom);
// When the mouse enters and leaves the discussions pane, we want to show
// and hide the pane respectively. We also create a 10px 'hot edge' on the
// left of the screen to activate the pane.
const pane = app.pane;
$list.hover(pane.show.bind(pane), pane.onmouseleave.bind(pane));
$(document).on('mousemove', hotEdge);
// If the discussion we are viewing is listed in the discussion list, then
// we will make sure it is visible in the viewport if it is not we will
// scroll the list down to it.
const $discussion = $list.find('.DiscussionListItem.active');
if ($discussion.length) {
const listTop = $list.offset().top;
const listBottom = listTop + $list.outerHeight();
const discussionTop = $discussion.offset().top;
const discussionBottom = discussionTop + $discussion.outerHeight();
if (discussionTop < listTop || discussionBottom > listBottom) {
$list.scrollTop($list.scrollTop() - listTop + discussionTop);
}
}
}
onremove() {
$(document).off('mousemove', hotEdge);
}
/**
* Are we on a device that's larger than we consider "mobile"?
*
* @returns {boolean}
*/
enoughSpace() {
return !$('.App-navigation').is(':visible');
}
}

View File

@@ -1,13 +1,13 @@
import Page from '../../common/components/Page';
import ItemList from '../../common/utils/ItemList';
import DiscussionHero from './DiscussionHero';
import DiscussionListPane from './DiscussionListPane';
import PostStream from './PostStream';
import PostStreamScrubber from './PostStreamScrubber';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import SplitDropdown from '../../common/components/SplitDropdown';
import listItems from '../../common/helpers/listItems';
import DiscussionControls from '../utils/DiscussionControls';
import DiscussionList from './DiscussionList';
import PostStreamState from '../states/PostStreamState';
/**
@@ -15,8 +15,8 @@ import PostStreamState from '../states/PostStreamState';
* the discussion list pane, the hero, the posts, and the sidebar.
*/
export default class DiscussionPage extends Page {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
/**
* The discussion that is being viewed.
@@ -42,38 +42,16 @@ export default class DiscussionPage extends Page {
if (app.discussions.hasDiscussions()) {
app.pane.enable();
app.pane.hide();
if (app.previous.matches(DiscussionPage)) {
m.redraw.strategy('diff');
}
}
app.history.push('discussion');
this.bodyClass = 'App--discussion';
this.prevRoute = m.route.get();
}
onunload(e) {
// If we have routed to the same discussion as we were viewing previously,
// cancel the unloading of this controller and instead prompt the post
// stream to jump to the new 'near' param.
if (this.discussion) {
const idParam = m.route.param('id');
if (idParam && idParam.split('-')[0] === this.discussion.id()) {
e.preventDefault();
const near = m.route.param('near') || '1';
if (near !== String(this.near)) {
this.stream.goToNumber(near);
}
this.near = null;
return;
}
}
onremove() {
// If we are indeed navigating away from this discussion, then disable the
// discussion list pane. Also, if we're composing a reply to this
// discussion, minimize the composer unless it's empty, in which case
@@ -92,14 +70,7 @@ export default class DiscussionPage extends Page {
return (
<div className="DiscussionPage">
{app.discussions.hasDiscussions() ? (
<div className="DiscussionPage-list" config={this.configPane.bind(this)}>
{!$('.App-navigation').is(':visible') && <DiscussionList state={app.discussions} />}
</div>
) : (
''
)}
<DiscussionListPane state={app.discussions} />
<div className="DiscussionPage-discussion">
{discussion
? [
@@ -124,11 +95,30 @@ export default class DiscussionPage extends Page {
);
}
config(...args) {
super.config(...args);
onbeforeupdate(vnode) {
super.onbeforeupdate(vnode);
if (this.discussion) {
app.setTitle(this.discussion.title());
if (m.route.get() !== this.prevRoute) {
this.prevRoute = m.route.get();
// If we have routed to the same discussion as we were viewing previously,
// cancel the unloading of this controller and instead prompt the post
// stream to jump to the new 'near' param.
if (this.discussion) {
const idParam = m.route.param('id');
if (idParam && idParam.split('-')[0] === this.discussion.id()) {
const near = m.route.param('near') || '1';
if (near !== String(this.near)) {
this.stream.goToNumber(near);
}
this.near = near;
} else {
this.oninit(vnode);
}
}
}
}
@@ -149,7 +139,7 @@ export default class DiscussionPage extends Page {
app.store.find('discussions', m.route.param('id').split('-')[0], params).then(this.show.bind(this));
}
m.lazyRedraw();
m.redraw();
}
/**
@@ -173,6 +163,7 @@ export default class DiscussionPage extends Page {
this.discussion = discussion;
app.history.push('discussion', discussion.title());
app.setTitle(this.discussion.title());
app.setTitleCount(0);
// When the API responds with a discussion, it will also include a number of
@@ -209,48 +200,6 @@ export default class DiscussionPage extends Page {
app.current.set('stream', this.stream);
}
/**
* Configure the discussion list pane.
*
* @param {DOMElement} element
* @param {Boolean} isInitialized
* @param {Object} context
*/
configPane(element, isInitialized, context) {
if (isInitialized) return;
context.retain = true;
const $list = $(element);
// When the mouse enters and leaves the discussions pane, we want to show
// and hide the pane respectively. We also create a 10px 'hot edge' on the
// left of the screen to activate the pane.
const pane = app.pane;
$list.hover(pane.show.bind(pane), pane.onmouseleave.bind(pane));
const hotEdge = (e) => {
if (e.pageX < 10) pane.show();
};
$(document).on('mousemove', hotEdge);
context.onunload = () => $(document).off('mousemove', hotEdge);
// If the discussion we are viewing is listed in the discussion list, then
// we will make sure it is visible in the viewport if it is not we will
// scroll the list down to it.
const $discussion = $list.find('.DiscussionListItem.active');
if ($discussion.length) {
const listTop = $list.offset().top;
const listBottom = listTop + $list.outerHeight();
const discussionTop = $discussion.offset().top;
const discussionBottom = discussionTop + $discussion.outerHeight();
if (discussionTop < listTop || discussionBottom > listBottom) {
$list.scrollTop($list.scrollTop() - listTop + discussionTop);
}
}
}
/**
* Build an item list for the contents of the sidebar.
*
@@ -261,12 +210,14 @@ export default class DiscussionPage extends Page {
items.add(
'controls',
SplitDropdown.component({
children: DiscussionControls.controls(this.discussion, this).toArray(),
icon: 'fas fa-ellipsis-v',
className: 'App-primaryControl',
buttonClassName: 'Button--primary',
})
SplitDropdown.component(
{
icon: 'fas fa-ellipsis-v',
className: 'App-primaryControl',
buttonClassName: 'Button--primary',
},
DiscussionControls.controls(this.discussion, this).toArray()
)
);
items.add(
@@ -295,7 +246,8 @@ export default class DiscussionPage extends Page {
// replace it into the window's history and our own history stack.
const url = app.route.discussion(discussion, (this.near = startNumber));
m.route(url, true);
this.prevRoute = url;
m.route.set(url, null, { replace: true });
window.history.replaceState(null, document.title, url);
app.history.push('discussion', discussion.title());

View File

@@ -4,9 +4,9 @@ import Notification from './Notification';
* The `DiscussionRenamedNotification` component displays a notification which
* indicates that a discussion has had its title changed.
*
* ### Props
* ### Attrs
*
* - All of the props for Notification
* - All of the attrs for Notification
*/
export default class DiscussionRenamedNotification extends Notification {
icon() {
@@ -14,12 +14,12 @@ export default class DiscussionRenamedNotification extends Notification {
}
href() {
const notification = this.props.notification;
const notification = this.attrs.notification;
return app.route.discussion(notification.subject(), notification.content().postNumber);
}
content() {
return app.translator.trans('core.forum.notifications.discussion_renamed_text', { user: this.props.notification.fromUser() });
return app.translator.trans('core.forum.notifications.discussion_renamed_text', { user: this.attrs.notification.fromUser() });
}
}

View File

@@ -5,9 +5,9 @@ import extractText from '../../common/utils/extractText';
* The `DiscussionRenamedPost` component displays a discussion event post
* indicating that the discussion has been renamed.
*
* ### Props
* ### Attrs
*
* - All of the props for EventPost
* - All of the attrs for EventPost
*/
export default class DiscussionRenamedPost extends EventPost {
icon() {
@@ -22,7 +22,7 @@ export default class DiscussionRenamedPost extends EventPost {
}
descriptionData() {
const post = this.props.post;
const post = this.attrs.post;
const oldTitle = post.content()[0];
const newTitle = post.content()[1];

View File

@@ -34,18 +34,20 @@ export default class DiscussionsSearchSource {
return [
<li className="Dropdown-header">{app.translator.trans('core.forum.search.discussions_heading')}</li>,
<li>
{LinkButton.component({
icon: 'fas fa-search',
children: app.translator.trans('core.forum.search.all_discussions_button', { query }),
href: app.route('index', { q: query }),
})}
{LinkButton.component(
{
icon: 'fas fa-search',
href: app.route('index', { q: query }),
},
app.translator.trans('core.forum.search.all_discussions_button', { query })
)}
</li>,
results.map((discussion) => {
const mostRelevantPost = discussion.mostRelevantPost();
return (
<li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()}>
<a href={app.route.discussion(discussion, mostRelevantPost && mostRelevantPost.number())} config={m.route}>
<a route={app.route.discussion(discussion, mostRelevantPost && mostRelevantPost.number())}>
<div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div>
{mostRelevantPost ? <div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain(), query, 100)}</div> : ''}
</a>

View File

@@ -7,8 +7,8 @@ import DiscussionListState from '../states/DiscussionListState';
* page.
*/
export default class DiscussionsUserPage extends UserPage {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
this.loadUser(m.route.param('username'));
}

View File

@@ -14,38 +14,32 @@ function minimizeComposerIfFullScreen(e) {
* post. It sets the initial content to the content of the post that is being
* edited, and adds a header control to indicate which post is being edited.
*
* ### Props
* ### Attrs
*
* - All of the props for ComposerBody
* - All of the attrs for ComposerBody
* - `post`
*/
export default class EditPostComposer extends ComposerBody {
static initProps(props) {
super.initProps(props);
static initAttrs(attrs) {
super.initAttrs(attrs);
props.submitLabel = props.submitLabel || app.translator.trans('core.forum.composer_edit.submit_button');
props.confirmExit = props.confirmExit || app.translator.trans('core.forum.composer_edit.discard_confirmation');
props.originalContent = props.originalContent || props.post.content();
props.user = props.user || props.post.user();
attrs.submitLabel = attrs.submitLabel || app.translator.trans('core.forum.composer_edit.submit_button');
attrs.confirmExit = attrs.confirmExit || app.translator.trans('core.forum.composer_edit.discard_confirmation');
attrs.originalContent = attrs.originalContent || attrs.post.content();
attrs.user = attrs.user || attrs.post.user();
props.post.editedContent = props.originalContent;
attrs.post.editedContent = attrs.originalContent;
}
headerItems() {
const items = super.headerItems();
const post = this.props.post;
const routeAndMinimize = function (element, isInitialized) {
if (isInitialized) return;
$(element).on('click', minimizeComposerIfFullScreen);
m.route.apply(this, arguments);
};
const post = this.attrs.post;
items.add(
'title',
<h3>
{icon('fas fa-pencil-alt')}{' '}
<a href={app.route.discussion(post.discussion(), post.number())} config={routeAndMinimize}>
<a route={app.route.discussion(post.discussion(), post.number())} onclick={minimizeComposerIfFullScreen}>
{app.translator.trans('core.forum.composer_edit.post_link', { number: post.number(), discussion: post.discussion().title() })}
</a>
</h3>
@@ -60,7 +54,7 @@ export default class EditPostComposer extends ComposerBody {
jumpToPreview(e) {
minimizeComposerIfFullScreen(e);
m.route(app.route.post(this.props.post));
m.route.set(app.route.post(this.attrs.post));
}
/**
@@ -75,13 +69,13 @@ export default class EditPostComposer extends ComposerBody {
}
onsubmit() {
const discussion = this.props.post.discussion();
const discussion = this.attrs.post.discussion();
this.loading = true;
const data = this.data();
this.props.post.save(data).then((post) => {
this.attrs.post.save(data).then((post) => {
// If we're currently viewing the discussion which this edit was made
// in, then we can scroll to the post.
if (app.viewingDiscussion(discussion)) {
@@ -91,17 +85,18 @@ export default class EditPostComposer extends ComposerBody {
// their edit has been made, containing a button which will
// transition to their edited post when clicked.
let alert;
const viewButton = Button.component({
className: 'Button Button--link',
children: app.translator.trans('core.forum.composer_edit.view_button'),
onclick: () => {
m.route(app.route.post(post));
app.alerts.dismiss(alert);
const viewButton = Button.component(
{
className: 'Button Button--link',
onclick: () => {
m.route.set(app.route.post(post));
app.alerts.dismiss(alert);
},
},
});
alert = app.alerts.show({
app.translator.trans('core.forum.composer_edit.view_button')
);
alert = app.alerts.show(app.translator.trans('core.forum.composer_edit.edited_message'), {
type: 'success',
children: app.translator.trans('core.forum.composer_edit.edited_message'),
controls: [viewButton],
});
}

View File

@@ -9,22 +9,22 @@ import ItemList from '../../common/utils/ItemList';
* The `EditUserModal` component displays a modal dialog with a login form.
*/
export default class EditUserModal extends Modal {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
const user = this.props.user;
const user = this.attrs.user;
this.username = m.prop(user.username() || '');
this.email = m.prop(user.email() || '');
this.isEmailConfirmed = m.prop(user.isEmailConfirmed() || false);
this.setPassword = m.prop(false);
this.password = m.prop(user.password() || '');
this.username = m.stream(user.username() || '');
this.email = m.stream(user.email() || '');
this.isEmailConfirmed = m.stream(user.isEmailConfirmed() || false);
this.setPassword = m.stream(false);
this.password = m.stream(user.password() || '');
this.groups = {};
app.store
.all('groups')
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
.forEach((group) => (this.groups[group.id()] = m.prop(user.groups().indexOf(group) !== -1)));
.forEach((group) => (this.groups[group.id()] = m.stream(user.groups().indexOf(group) !== -1)));
}
className() {
@@ -55,7 +55,7 @@ export default class EditUserModal extends Modal {
40
);
if (app.session.user !== this.props.user) {
if (app.session.user !== this.attrs.user) {
items.add(
'email',
<div className="Form-group">
@@ -65,12 +65,14 @@ export default class EditUserModal extends Modal {
</div>
{!this.isEmailConfirmed() ? (
<div>
{Button.component({
className: 'Button Button--block',
children: app.translator.trans('core.forum.edit_user.activate_button'),
loading: this.loading,
onclick: this.activate.bind(this),
})}
{Button.component(
{
className: 'Button Button--block',
loading: this.loading,
onclick: this.activate.bind(this),
},
app.translator.trans('core.forum.edit_user.activate_button')
)}
</div>
) : (
''
@@ -89,9 +91,9 @@ export default class EditUserModal extends Modal {
type="checkbox"
onchange={(e) => {
this.setPassword(e.target.checked);
m.redraw(true);
m.redraw.sync();
if (e.target.checked) this.$('[name=password]').select();
m.redraw.strategy('none');
e.redraw = false;
}}
/>
{app.translator.trans('core.forum.edit_user.set_password_label')}
@@ -125,7 +127,7 @@ export default class EditUserModal extends Modal {
<input
type="checkbox"
bidi={this.groups[group.id()]}
disabled={this.props.user.id() === '1' && group.id() === Group.ADMINISTRATOR_ID}
disabled={this.attrs.user.id() === '1' && group.id() === Group.ADMINISTRATOR_ID}
/>
{GroupBadge.component({ group, label: '' })} {group.nameSingular()}
</label>
@@ -138,12 +140,14 @@ export default class EditUserModal extends Modal {
items.add(
'submit',
<div className="Form-group">
{Button.component({
className: 'Button Button--primary',
type: 'submit',
loading: this.loading,
children: app.translator.trans('core.forum.edit_user.submit_button'),
})}
{Button.component(
{
className: 'Button Button--primary',
type: 'submit',
loading: this.loading,
},
app.translator.trans('core.forum.edit_user.submit_button')
)}
</div>,
-10
);
@@ -157,7 +161,7 @@ export default class EditUserModal extends Modal {
username: this.username(),
isEmailConfirmed: true,
};
this.props.user
this.attrs.user
.save(data, { errorHandler: this.onerror.bind(this) })
.then(() => {
this.isEmailConfirmed(true);
@@ -180,7 +184,7 @@ export default class EditUserModal extends Modal {
relationships: { groups },
};
if (app.session.user !== this.props.user) {
if (app.session.user !== this.attrs.user) {
data.email = this.email();
}
@@ -196,7 +200,7 @@ export default class EditUserModal extends Modal {
this.loading = true;
this.props.user
this.attrs.user
.save(this.data(), { errorHandler: this.onerror.bind(this) })
.then(this.hide.bind(this))
.catch(() => {

View File

@@ -8,28 +8,28 @@ import icon from '../../common/helpers/icon';
* event, like a discussion being renamed or stickied. Subclasses must implement
* the `icon` and `description` methods.
*
* ### Props
* ### Attrs
*
* - All of the props for `Post`
* - All of the attrs for `Post`
*
* @abstract
*/
export default class EventPost extends Post {
attrs() {
const attrs = super.attrs();
elementAttrs() {
const attrs = super.elementAttrs();
attrs.className = (attrs.className || '') + ' EventPost ' + ucfirst(this.props.post.contentType()) + 'Post';
attrs.className = (attrs.className || '') + ' EventPost ' + ucfirst(this.attrs.post.contentType()) + 'Post';
return attrs;
}
content() {
const user = this.props.post.user();
const user = this.attrs.post.user();
const username = usernameHelper(user);
const data = Object.assign(this.descriptionData(), {
user,
username: user ? (
<a className="EventPost-user" href={app.route.user(user)} config={m.route}>
<a className="EventPost-user" route={app.route.user(user)}>
{username}
</a>
) : (

View File

@@ -1,5 +1,4 @@
import Modal from '../../common/components/Modal';
import Alert from '../../common/components/Alert';
import Button from '../../common/components/Button';
import extractText from '../../common/utils/extractText';
@@ -7,20 +6,20 @@ import extractText from '../../common/utils/extractText';
* The `ForgotPasswordModal` component displays a modal which allows the user to
* enter their email address and request a link to reset their password.
*
* ### Props
* ### Attrs
*
* - `email`
*/
export default class ForgotPasswordModal extends Modal {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
/**
* The value of the email input.
*
* @type {Function}
*/
this.email = m.prop(this.props.email || '');
this.email = m.stream(this.attrs.email || '');
/**
* Whether or not the password reset email was sent successfully.
@@ -64,18 +63,19 @@ export default class ForgotPasswordModal extends Modal {
name="email"
type="email"
placeholder={extractText(app.translator.trans('core.forum.forgot_password.email_placeholder'))}
value={this.email()}
onchange={m.withAttr('value', this.email)}
bidi={this.email}
disabled={this.loading}
/>
</div>
<div className="Form-group">
{Button.component({
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
children: app.translator.trans('core.forum.forgot_password.submit_button'),
})}
{Button.component(
{
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
},
app.translator.trans('core.forum.forgot_password.submit_button')
)}
</div>
</div>
</div>
@@ -91,7 +91,7 @@ export default class ForgotPasswordModal extends Modal {
.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/forgot',
data: { email: this.email() },
body: { email: this.email() },
errorHandler: this.onerror.bind(this),
})
.then(() => {
@@ -104,7 +104,7 @@ export default class ForgotPasswordModal extends Modal {
onerror(error) {
if (error.status === 404) {
error.alert.children = app.translator.trans('core.forum.forgot_password.not_found_message');
error.alert.content = app.translator.trans('core.forum.forgot_password.not_found_message');
}
super.onerror(error);

View File

@@ -11,13 +11,6 @@ export default class HeaderPrimary extends Component {
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
}
config(isInitialized, context) {
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
}
/**
* Build an item list for the controls.
*

View File

@@ -19,13 +19,6 @@ export default class HeaderSecondary extends Component {
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
}
config(isInitialized, context) {
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
}
/**
* Build an item list for the controls.
*
@@ -41,28 +34,32 @@ export default class HeaderSecondary extends Component {
for (const locale in app.data.locales) {
locales.push(
Button.component({
active: app.data.locale === locale,
children: app.data.locales[locale],
icon: app.data.locale === locale ? 'fas fa-check' : true,
onclick: () => {
if (app.session.user) {
app.session.user.savePreferences({ locale }).then(() => window.location.reload());
} else {
document.cookie = `locale=${locale}; path=/; expires=Tue, 19 Jan 2038 03:14:07 GMT`;
window.location.reload();
}
Button.component(
{
active: app.data.locale === locale,
icon: app.data.locale === locale ? 'fas fa-check' : true,
onclick: () => {
if (app.session.user) {
app.session.user.savePreferences({ locale }).then(() => window.location.reload());
} else {
document.cookie = `locale=${locale}; path=/; expires=Tue, 19 Jan 2038 03:14:07 GMT`;
window.location.reload();
}
},
},
})
app.data.locales[locale]
)
);
}
items.add(
'locale',
SelectDropdown.component({
children: locales,
buttonClassName: 'Button Button--link',
}),
SelectDropdown.component(
{
buttonClassName: 'Button Button--link',
},
locales
),
20
);
}
@@ -74,22 +71,26 @@ export default class HeaderSecondary extends Component {
if (app.forum.attribute('allowSignUp')) {
items.add(
'signUp',
Button.component({
children: app.translator.trans('core.forum.header.sign_up_link'),
className: 'Button Button--link',
onclick: () => app.modal.show(SignUpModal),
}),
Button.component(
{
className: 'Button Button--link',
onclick: () => app.modal.show(SignUpModal),
},
app.translator.trans('core.forum.header.sign_up_link')
),
10
);
}
items.add(
'logIn',
Button.component({
children: app.translator.trans('core.forum.header.log_in_link'),
className: 'Button Button--link',
onclick: () => app.modal.show(LogInModal),
}),
Button.component(
{
className: 'Button Button--link',
onclick: () => app.modal.show(LogInModal),
},
app.translator.trans('core.forum.header.log_in_link')
),
0
);
}

View File

@@ -19,8 +19,8 @@ import SelectDropdown from '../../common/components/SelectDropdown';
export default class IndexPage extends Page {
static providesInitialSearch = true;
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
// If the user is returning from a discussion page, then take note of which
// discussion they have just visited. After the view is rendered, we will
@@ -42,12 +42,26 @@ export default class IndexPage extends Page {
app.history.push('index', app.translator.trans('core.forum.header.back_to_index_tooltip'));
this.bodyClass = 'App--index';
this.currentPath = m.route.get();
}
onunload() {
// Save the scroll position so we can restore it when we return to the
// discussion list.
app.cache.scrollTop = $(window).scrollTop();
onbeforeupdate(vnode) {
super.onbeforeupdate(vnode);
const curPath = m.route.get();
if (this.currentPath !== curPath) {
this.onNewRoute();
app.discussions.clear();
app.discussions.refreshParams(app.search.params());
this.currentPath = curPath;
this.setTitle();
}
}
view() {
@@ -72,15 +86,15 @@ export default class IndexPage extends Page {
);
}
config(isInitialized, context) {
super.config(...arguments);
if (isInitialized) return;
extend(context, 'onunload', () => $('#app').css('min-height', ''));
setTitle() {
app.setTitle(app.translator.trans('core.forum.index.meta_title_text'));
app.setTitleCount(0);
}
oncreate(vnode) {
super.oncreate(vnode);
this.setTitle();
// Work out the difference between the height of this hero and that of the
// previous hero. Maintain the same scroll position relative to the bottom
@@ -117,6 +131,16 @@ export default class IndexPage extends Page {
}
}
onremove() {
super.onremove();
$('#app').css('min-height', '');
// Save the scroll position so we can restore it when we return to the
// discussion list.
app.cache.scrollTop = $(window).scrollTop();
}
/**
* Get the component to display as the hero.
*
@@ -139,25 +163,31 @@ export default class IndexPage extends Page {
items.add(
'newDiscussion',
Button.component({
children: app.translator.trans(
canStartDiscussion ? 'core.forum.index.start_discussion_button' : 'core.forum.index.cannot_start_discussion_button'
),
icon: 'fas fa-edit',
className: 'Button Button--primary IndexPage-newDiscussion',
itemClassName: 'App-primaryControl',
onclick: this.newDiscussionAction.bind(this),
disabled: !canStartDiscussion,
})
Button.component(
{
icon: 'fas fa-edit',
className: 'Button Button--primary IndexPage-newDiscussion',
itemClassName: 'App-primaryControl',
onclick: () => {
// If the user is not logged in, the promise rejects, and a login modal shows up.
// Since that's already handled, we dont need to show an error message in the console.
return this.newDiscussionAction().catch(() => {});
},
disabled: !canStartDiscussion,
},
app.translator.trans(canStartDiscussion ? 'core.forum.index.start_discussion_button' : 'core.forum.index.cannot_start_discussion_button')
)
);
items.add(
'nav',
SelectDropdown.component({
children: this.navItems(this).toArray(),
buttonClassName: 'Button',
className: 'App-titleControl',
})
SelectDropdown.component(
{
buttonClassName: 'Button',
className: 'App-titleControl',
},
this.navItems(this).toArray()
)
);
return items;
@@ -175,11 +205,13 @@ export default class IndexPage extends Page {
items.add(
'allDiscussions',
LinkButton.component({
href: app.route('index', params),
children: app.translator.trans('core.forum.index.all_discussions_link'),
icon: 'far fa-comments',
}),
LinkButton.component(
{
href: app.route('index', params),
icon: 'far fa-comments',
},
app.translator.trans('core.forum.index.all_discussions_link')
),
100
);
@@ -204,21 +236,25 @@ export default class IndexPage extends Page {
items.add(
'sort',
Dropdown.component({
buttonClassName: 'Button',
label: sortOptions[app.search.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0],
children: Object.keys(sortOptions).map((value) => {
Dropdown.component(
{
buttonClassName: 'Button',
label: sortOptions[app.search.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0],
},
Object.keys(sortOptions).map((value) => {
const label = sortOptions[value];
const active = (app.search.params().sort || Object.keys(sortMap)[0]) === value;
return Button.component({
children: label,
icon: active ? 'fas fa-check' : true,
onclick: app.search.changeSort.bind(app.search, value),
active: active,
});
}),
})
return Button.component(
{
icon: active ? 'fas fa-check' : true,
onclick: app.search.changeSort.bind(app.search, value),
active: active,
},
label
);
})
)
);
return items;
@@ -270,20 +306,18 @@ export default class IndexPage extends Page {
* @return {Promise}
*/
newDiscussionAction() {
const deferred = m.deferred();
return new Promise((resolve, reject) => {
if (app.session.user) {
app.composer.load(DiscussionComposer, { user: app.session.user });
app.composer.show();
if (app.session.user) {
app.composer.load(DiscussionComposer, { user: app.session.user });
app.composer.show();
return resolve(app.composer);
} else {
app.modal.show(LogInModal);
deferred.resolve(app.composer);
} else {
deferred.reject();
app.modal.show(LogInModal);
}
return deferred.promise;
return reject();
}
});
}
/**

View File

@@ -4,21 +4,21 @@ import Button from '../../common/components/Button';
* The `LogInButton` component displays a social login button which will open
* a popup window containing the specified path.
*
* ### Props
* ### Attrs
*
* - `path`
*/
export default class LogInButton extends Button {
static initProps(props) {
props.className = (props.className || '') + ' LogInButton';
static initAttrs(attrs) {
attrs.className = (attrs.className || '') + ' LogInButton';
props.onclick = function () {
attrs.onclick = function () {
const width = 580;
const height = 400;
const $window = $(window);
window.open(
app.forum.attribute('baseUrl') + props.path,
app.forum.attribute('baseUrl') + attrs.path,
'logInPopup',
`width=${width},` +
`height=${height},` +
@@ -28,6 +28,6 @@ export default class LogInButton extends Button {
);
};
super.initProps(props);
super.initAttrs(attrs);
}
}

View File

@@ -9,35 +9,35 @@ import ItemList from '../../common/utils/ItemList';
/**
* The `LogInModal` component displays a modal dialog with a login form.
*
* ### Props
* ### Attrs
*
* - `identification`
* - `password`
*/
export default class LogInModal extends Modal {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
/**
* The value of the identification input.
*
* @type {Function}
*/
this.identification = m.prop(this.props.identification || '');
this.identification = m.stream(this.attrs.identification || '');
/**
* The value of the password input.
*
* @type {Function}
*/
this.password = m.prop(this.props.password || '');
this.password = m.stream(this.attrs.password || '');
/**
* The value of the remember me input.
*
* @type {Function}
*/
this.remember = m.prop(!!this.props.remember);
this.remember = m.stream(!!this.attrs.remember);
}
className() {
@@ -105,12 +105,14 @@ export default class LogInModal extends Modal {
items.add(
'submit',
<div className="Form-group">
{Button.component({
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
children: app.translator.trans('core.forum.log_in.submit_button'),
})}
{Button.component(
{
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
},
app.translator.trans('core.forum.log_in.submit_button')
)}
</div>,
-10
);
@@ -140,9 +142,9 @@ export default class LogInModal extends Modal {
*/
forgotPassword() {
const email = this.identification();
const props = email.indexOf('@') !== -1 ? { email } : undefined;
const attrs = email.indexOf('@') !== -1 ? { email } : undefined;
app.modal.show(ForgotPasswordModal, props);
app.modal.show(ForgotPasswordModal, attrs);
}
/**
@@ -152,11 +154,11 @@ export default class LogInModal extends Modal {
* @public
*/
signUp() {
const props = { password: this.password() };
const attrs = { password: this.password() };
const identification = this.identification();
props[identification.indexOf('@') !== -1 ? 'email' : 'username'] = identification;
attrs[identification.indexOf('@') !== -1 ? 'email' : 'username'] = identification;
app.modal.show(SignUpModal, props);
app.modal.show(SignUpModal, attrs);
}
onready() {
@@ -179,7 +181,7 @@ export default class LogInModal extends Modal {
onerror(error) {
if (error.status === 401) {
error.alert.children = app.translator.trans('core.forum.log_in.invalid_login_message');
error.alert.content = app.translator.trans('core.forum.log_in.invalid_login_message');
}
super.onerror(error);

View File

@@ -8,7 +8,7 @@ import Button from '../../common/components/Button';
* The `Notification` component abstract displays a single notification.
* Subclasses should implement the `icon`, `href`, and `content` methods.
*
* ### Props
* ### Attrs
*
* - `notification`
*
@@ -16,18 +16,17 @@ import Button from '../../common/components/Button';
*/
export default class Notification extends Component {
view() {
const notification = this.props.notification;
const notification = this.attrs.notification;
const href = this.href();
const linkAttrs = {};
linkAttrs[href.indexOf('://') === -1 ? 'route' : 'href'] = href;
return (
<a
className={'Notification Notification--' + notification.contentType() + ' ' + (!notification.isRead() ? 'unread' : '')}
href={href}
config={function (element, isInitialized) {
if (href.indexOf('://') === -1) m.route.apply(this, arguments);
if (!isInitialized) $(element).click(this.markAsRead.bind(this));
}}
{...linkAttrs}
onclick={this.markAsRead.bind(this)}
>
{!notification.isRead() &&
Button.component({
@@ -86,10 +85,10 @@ export default class Notification extends Component {
* Mark the notification as read.
*/
markAsRead() {
if (this.props.notification.isRead()) return;
if (this.attrs.notification.isRead()) return;
app.session.user.pushAttributes({ unreadNotificationCount: app.session.user.unreadNotificationCount() - 1 });
this.props.notification.save({ isRead: true });
this.attrs.notification.save({ isRead: true });
}
}

View File

@@ -7,12 +7,14 @@ import ItemList from '../../common/utils/ItemList';
* The `NotificationGrid` component displays a table of notification types and
* methods, allowing the user to toggle each combination.
*
* ### Props
* ### Attrs
*
* - `user`
*/
export default class NotificationGrid extends Component {
init() {
oninit(vnode) {
super.oninit(vnode);
/**
* Information about the available notification methods.
*
@@ -36,7 +38,7 @@ export default class NotificationGrid extends Component {
}
view() {
const preferences = this.props.user.preferences();
const preferences = this.attrs.user.preferences();
return (
<table className="NotificationGrid">
@@ -62,12 +64,12 @@ export default class NotificationGrid extends Component {
return (
<td className="NotificationGrid-checkbox">
{Checkbox.component({
state: !!preferences[key],
loading: this.loading[key],
disabled: !(key in preferences),
onchange: () => this.toggle([key]),
})}
<Checkbox
state={!!preferences[key]}
loading={this.loading[key]}
disabled={!(key in preferences)}
onchange={this.toggle.bind(this, [key])}
/>
</td>
);
})}
@@ -78,8 +80,8 @@ export default class NotificationGrid extends Component {
);
}
config(isInitialized) {
if (isInitialized) return;
oncreate(vnode) {
super.oncreate(vnode);
this.$('thead .NotificationGrid-groupToggle').bind('mouseenter mouseleave', function (e) {
const i = parseInt($(this).index(), 10) + 1;
@@ -104,7 +106,7 @@ export default class NotificationGrid extends Component {
* @param {Array} keys
*/
toggle(keys) {
const user = this.props.user;
const user = this.attrs.user;
const preferences = user.preferences();
const enabled = !preferences[keys[0]];
@@ -128,7 +130,7 @@ export default class NotificationGrid extends Component {
* @param {String} method
*/
toggleMethod(method) {
const keys = this.types.map((type) => this.preferenceKey(type.name, method)).filter((key) => key in this.props.user.preferences());
const keys = this.types.map((type) => this.preferenceKey(type.name, method)).filter((key) => key in this.attrs.user.preferences());
this.toggle(keys);
}
@@ -139,7 +141,7 @@ export default class NotificationGrid extends Component {
* @param {String} type
*/
toggleType(type) {
const keys = this.methods.map((method) => this.preferenceKey(type, method.name)).filter((key) => key in this.props.user.preferences());
const keys = this.methods.map((method) => this.preferenceKey(type, method.name)).filter((key) => key in this.attrs.user.preferences());
this.toggle(keys);
}

View File

@@ -9,12 +9,9 @@ import Discussion from '../../common/models/Discussion';
* notifications, grouped by discussion.
*/
export default class NotificationList extends Component {
init() {
this.state = this.props.state;
}
view() {
const pages = this.state.getNotificationPages();
const state = this.attrs.state;
const pages = state.getNotificationPages();
return (
<div className="NotificationList">
@@ -24,7 +21,7 @@ export default class NotificationList extends Component {
className: 'Button Button--icon Button--link',
icon: 'fas fa-check',
title: app.translator.trans('core.forum.notifications.mark_all_as_read_tooltip'),
onclick: this.state.markAllAsRead.bind(this.state),
onclick: state.markAllAsRead.bind(state),
})}
</div>
@@ -66,7 +63,7 @@ export default class NotificationList extends Component {
return (
<div className="NotificationGroup">
{group.discussion ? (
<a className="NotificationGroup-header" href={app.route.discussion(group.discussion)} config={m.route}>
<a className="NotificationGroup-header" route={app.route.discussion(group.discussion)}>
{badges && badges.length ? <ul className="NotificationGroup-badges badges">{listItems(badges)}</ul> : ''}
{group.discussion.title()}
</a>
@@ -85,7 +82,7 @@ export default class NotificationList extends Component {
});
})
: ''}
{this.state.isLoading() ? (
{state.isLoading() ? (
<LoadingIndicator className="LoadingIndicator--block" />
) : pages.length ? (
''
@@ -97,27 +94,31 @@ export default class NotificationList extends Component {
);
}
config(isInitialized, context) {
if (isInitialized) return;
oncreate(vnode) {
super.oncreate(vnode);
const $notifications = this.$('.NotificationList-content');
const $scrollParent = $notifications.css('overflow') === 'auto' ? $notifications : $(window);
this.$notifications = this.$('.NotificationList-content');
this.$scrollParent = this.$notifications.css('overflow') === 'auto' ? this.$notifications : $(window);
const scrollHandler = () => {
const scrollTop = $scrollParent.scrollTop();
const viewportHeight = $scrollParent.height();
const contentTop = $scrollParent === $notifications ? 0 : $notifications.offset().top;
const contentHeight = $notifications[0].scrollHeight;
this.boundScrollHandler = this.scrollHandler.bind(this);
this.$scrollParent.on('scroll', this.boundScrollHandler);
}
if (this.state.hasMoreResults() && !this.state.isLoading() && scrollTop + viewportHeight >= contentTop + contentHeight) {
this.state.loadMore();
}
};
onremove() {
this.$scrollParent.off('scroll', this.boundScrollHandler);
}
$scrollParent.on('scroll', scrollHandler);
scrollHandler() {
const state = this.attrs.state;
context.onunload = () => {
$scrollParent.off('scroll', scrollHandler);
};
const scrollTop = this.$scrollParent.scrollTop();
const viewportHeight = this.$scrollParent.height();
const contentTop = this.$scrollParent === this.$notifications ? 0 : this.$notifications.offset().top;
const contentHeight = this.$notifications[0].scrollHeight;
if (state.hasMoreResults() && !state.isLoading() && scrollTop + viewportHeight >= contentTop + contentHeight) {
state.loadMore();
}
}
}

View File

@@ -3,21 +3,21 @@ import icon from '../../common/helpers/icon';
import NotificationList from './NotificationList';
export default class NotificationsDropdown extends Dropdown {
static initProps(props) {
props.className = props.className || 'NotificationsDropdown';
props.buttonClassName = props.buttonClassName || 'Button Button--flat';
props.menuClassName = props.menuClassName || 'Dropdown-menu--right';
props.label = props.label || app.translator.trans('core.forum.notifications.tooltip');
props.icon = props.icon || 'fas fa-bell';
static initAttrs(attrs) {
attrs.className = attrs.className || 'NotificationsDropdown';
attrs.buttonClassName = attrs.buttonClassName || 'Button Button--flat';
attrs.menuClassName = attrs.menuClassName || 'Dropdown-menu--right';
attrs.label = attrs.label || app.translator.trans('core.forum.notifications.tooltip');
attrs.icon = attrs.icon || 'fas fa-bell';
super.initProps(props);
super.initAttrs(attrs);
}
getButton() {
const newNotifications = this.getNewCount();
const vdom = super.getButton();
vdom.attrs.title = this.props.label;
vdom.attrs.title = this.attrs.label;
vdom.attrs.className += newNotifications ? ' new' : '';
vdom.attrs.onclick = this.onclick.bind(this);
@@ -29,16 +29,16 @@ export default class NotificationsDropdown extends Dropdown {
const unread = this.getUnreadCount();
return [
icon(this.props.icon, { className: 'Button-icon' }),
icon(this.attrs.icon, { className: 'Button-icon' }),
unread ? <span className="NotificationsDropdown-unread">{unread}</span> : '',
<span className="Button-label">{this.props.label}</span>,
<span className="Button-label">{this.attrs.label}</span>,
];
}
getMenu() {
return (
<div className={'Dropdown-menu ' + this.props.menuClassName} onclick={this.menuClick.bind(this)}>
{this.showing ? NotificationList.component({ state: this.props.state }) : ''}
<div className={'Dropdown-menu ' + this.attrs.menuClassName} onclick={this.menuClick.bind(this)}>
{this.showing ? NotificationList.component({ state: this.attrs.state }) : ''}
</div>
);
}
@@ -47,12 +47,12 @@ export default class NotificationsDropdown extends Dropdown {
if (app.drawer.isOpen()) {
this.goToRoute();
} else {
this.props.state.load();
this.attrs.state.load();
}
}
goToRoute() {
m.route(app.route('notifications'));
m.route.set(app.route('notifications'));
}
getUnreadCount() {

View File

@@ -6,8 +6,8 @@ import NotificationList from './NotificationList';
* used on mobile devices where the notifications dropdown is within the drawer.
*/
export default class NotificationsPage extends Page {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
app.history.push('notifications');

View File

@@ -10,14 +10,16 @@ import ItemList from '../../common/utils/ItemList';
* includes a controls dropdown; subclasses must implement `content` and `attrs`
* methods.
*
* ### Props
* ### Attrs
*
* - `post`
*
* @abstract
*/
export default class Post extends Component {
init() {
oninit(vnode) {
super.oninit(vnode);
this.loading = false;
/**
@@ -27,9 +29,9 @@ export default class Post extends Component {
* @type {SubtreeRetainer}
*/
this.subtree = new SubtreeRetainer(
() => this.props.post.freshness,
() => this.attrs.post.freshness,
() => {
const user = this.props.post.user();
const user = this.attrs.post.user();
return user && user.freshness;
},
() => this.controlsOpen
@@ -37,51 +39,52 @@ export default class Post extends Component {
}
view() {
const attrs = this.attrs();
const attrs = this.elementAttrs();
attrs.className = this.classes(attrs.className).join(' ');
const controls = PostControls.controls(this.attrs.post, this).toArray();
return (
<article {...attrs}>
{this.subtree.retain() ||
(() => {
const controls = PostControls.controls(this.props.post, this).toArray();
return (
<div>
{this.content()}
<aside className="Post-actions">
<ul>
{listItems(this.actionItems().toArray())}
{controls.length ? (
<li>
<Dropdown
className="Post-controls"
buttonClassName="Button Button--icon Button--flat"
menuClassName="Dropdown-menu--right"
icon="fas fa-ellipsis-h"
onshow={() => this.$('.Post-actions').addClass('open')}
onhide={() => this.$('.Post-actions').removeClass('open')}
>
{controls}
</Dropdown>
</li>
) : (
''
)}
</ul>
</aside>
<footer className="Post-footer">
<ul>{listItems(this.footerItems().toArray())}</ul>
</footer>
</div>
);
})()}
<div>
{this.content()}
<aside className="Post-actions">
<ul>
{listItems(this.actionItems().toArray())}
{controls.length ? (
<li>
<Dropdown
className="Post-controls"
buttonClassName="Button Button--icon Button--flat"
menuClassName="Dropdown-menu--right"
icon="fas fa-ellipsis-h"
onshow={() => this.$('.Post-actions').addClass('open')}
onhide={() => this.$('.Post-actions').removeClass('open')}
>
{controls}
</Dropdown>
</li>
) : (
''
)}
</ul>
</aside>
<footer className="Post-footer">
<ul>{listItems(this.footerItems().toArray())}</ul>
</footer>
</div>
</article>
);
}
config(isInitialized) {
onbeforeupdate(vnode) {
super.onbeforeupdate(vnode);
return this.subtree.needsRebuild();
}
onupdate() {
const $actions = this.$('.Post-actions');
const $controls = this.$('.Post-controls');
@@ -93,7 +96,7 @@ export default class Post extends Component {
*
* @return {Object}
*/
attrs() {
elementAttrs() {
return {};
}
@@ -115,8 +118,8 @@ export default class Post extends Component {
classes(existing) {
let classes = (existing || '').split(' ').concat(['Post']);
const user = this.props.post.user();
const discussion = this.props.post.discussion();
const user = this.attrs.post.user();
const discussion = this.attrs.post.discussion();
if (this.loading) {
classes.push('Post--loading');

View File

@@ -6,18 +6,20 @@ import extractText from '../../common/utils/extractText';
* The `PostEdited` component displays information about when and by whom a post
* was edited.
*
* ### Props
* ### Attrs
*
* - `post`
*/
export default class PostEdited extends Component {
init() {
oninit(vnode) {
super.oninit(vnode);
this.shouldUpdateTooltip = false;
this.oldEditedInfo = null;
}
view() {
const post = this.props.post;
const post = this.attrs.post;
const editedUser = post.editedUser();
const editedInfo = extractText(app.translator.trans('core.forum.post.edited_tooltip', { user: editedUser, ago: humanTime(post.editedAt()) }));
if (editedInfo !== this.oldEditedInfo) {
@@ -32,7 +34,17 @@ export default class PostEdited extends Component {
);
}
config(isInitialized) {
oncreate(vnode) {
super.oncreate(vnode);
this.rebuildTooltip();
}
onupdate() {
this.rebuildTooltip();
}
rebuildTooltip() {
if (this.shouldUpdateTooltip) {
this.$().tooltip('destroy').tooltip();
this.shouldUpdateTooltip = false;

View File

@@ -7,23 +7,23 @@ import fullTime from '../../common/helpers/fullTime';
* a dropdown containing more information about the post (number, full time,
* permalink).
*
* ### Props
* ### Attrs
*
* - `post`
*/
export default class PostMeta extends Component {
view() {
const post = this.props.post;
const post = this.attrs.post;
const time = post.createdAt();
const permalink = this.getPermalink(post);
const touch = 'ontouchstart' in document.documentElement;
// When the dropdown menu is shown, select the contents of the permalink
// input so that the user can quickly copy the URL.
const selectPermalink = function () {
const selectPermalink = function (e) {
setTimeout(() => $(this).parent().find('.PostMeta-permalink').select());
m.redraw.strategy('none');
e.redraw = false;
};
return (

View File

@@ -7,18 +7,18 @@ import highlight from '../../common/helpers/highlight';
* The `PostPreview` component shows a link to a post containing the avatar and
* username of the author, and a short excerpt of the post's content.
*
* ### Props
* ### Attrs
*
* - `post`
*/
export default class PostPreview extends Component {
view() {
const post = this.props.post;
const post = this.attrs.post;
const user = post.user();
const excerpt = highlight(post.contentPlain(), this.props.highlight, 300);
const excerpt = highlight(post.contentPlain(), this.attrs.highlight, 300);
return (
<a className="PostPreview" href={app.route.post(post)} config={m.route} onclick={this.props.onclick}>
<a className="PostPreview" route={app.route.post(post)} onclick={this.attrs.onclick}>
<span className="PostPreview-content">
{avatar(user)}
{username(user)} <span className="PostPreview-excerpt">{excerpt}</span>

View File

@@ -8,7 +8,7 @@ import Button from '../../common/components/Button';
* The `PostStream` component displays an infinitely-scrollable wall of posts in
* a discussion. Posts that have not loaded will be displayed as placeholders.
*
* ### Props
* ### Attrs
*
* - `discussion`
* - `stream`
@@ -16,14 +16,21 @@ import Button from '../../common/components/Button';
* - `onPositionChange`
*/
export default class PostStream extends Component {
init() {
this.discussion = this.props.discussion;
this.stream = this.props.stream;
oninit(vnode) {
super.oninit(vnode);
this.discussion = this.attrs.discussion;
this.stream = this.attrs.stream;
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
}
view() {
function fadeIn(element, isInitialized, context) {
if (!context.fadedIn) $(element).hide().fadeIn();
context.fadedIn = true;
}
let lastTime;
const viewingEnd = this.stream.viewingEnd();
@@ -40,6 +47,7 @@ export default class PostStream extends Component {
content = PostComponent ? PostComponent.component({ post }) : '';
attrs.key = 'post' + post.id();
attrs.config = fadeIn;
attrs['data-time'] = time.toISOString();
attrs['data-number'] = post.number();
attrs['data-id'] = post.id();
@@ -96,29 +104,33 @@ export default class PostStream extends Component {
return <div className="PostStream">{items}</div>;
}
config(isInitialized, context) {
onupdate() {
this.triggerScroll();
}
if (isInitialized) return;
oncreate(vnode) {
super.oncreate(vnode);
this.triggerScroll();
// This is wrapped in setTimeout due to the following Mithril issue:
// https://github.com/lhorie/mithril.js/issues/637
setTimeout(() => this.scrollListener.start());
}
context.onunload = () => {
this.scrollListener.stop();
clearTimeout(this.calculatePositionTimeout);
};
onremove() {
this.scrollListener.stop();
clearTimeout(this.calculatePositionTimeout);
}
/**
* Start scrolling, if appropriate, to a newly-targeted post.
*/
triggerScroll() {
if (!this.props.targetPost) return;
if (!this.attrs.targetPost) return;
const oldTarget = this.prevTarget;
const newTarget = this.props.targetPost;
const newTarget = this.attrs.targetPost;
if (oldTarget) {
if ('number' in oldTarget && oldTarget.number === newTarget.number) return;
@@ -265,7 +277,7 @@ export default class PostStream extends Component {
});
if (startNumber) {
this.props.onPositionChange(startNumber || 1, endNumber, startNumber);
this.attrs.onPositionChange(startNumber || 1, endNumber, startNumber);
}
}
@@ -348,7 +360,7 @@ export default class PostStream extends Component {
return Promise.all([$container.promise(), this.stream.loadPromise]).then(() => {
this.updateScrubber();
const index = $item.data('index');
m.redraw(true);
m.redraw.sync();
const scroll = index == 0 ? 0 : $(`.PostStream-item[data-index=${$item.data('index')}]`).offset().top - this.getMarginTop();
$(window).scrollTop(scroll);
this.calculatePosition();

View File

@@ -7,14 +7,16 @@ import ScrollListener from '../../common/utils/ScrollListener';
* The `PostStreamScrubber` component displays a scrubber which can be used to
* navigate/scrub through a post stream.
*
* ### Props
* ### Attrs
*
* - `stream`
* - `className`
*/
export default class PostStreamScrubber extends Component {
init() {
this.stream = this.props.stream;
oninit(vnode) {
super.oninit(vnode);
this.stream = this.attrs.stream;
this.handlers = {};
this.scrollListener = new ScrollListener(this.updateScrubberValues.bind(this, { fromScroll: true, forceHeightChange: true }));
@@ -32,23 +34,23 @@ export default class PostStreamScrubber extends Component {
const unreadCount = this.stream.discussion.unreadCount();
const unreadPercent = count ? Math.min(count - this.stream.index, unreadCount) / count : 0;
function styleUnread(element, isInitialized, context) {
const $element = $(element);
function styleUnread(vnode) {
const $element = $(vnode.dom);
const newStyle = {
top: 100 - unreadPercent * 100 + '%',
height: unreadPercent * 100 + '%',
};
if (context.oldStyle) {
$element.stop(true).css(context.oldStyle).animate(newStyle);
if (vnode.state.oldStyle) {
$element.stop(true).css(vnode.state.oldStyle).animate(newStyle);
} else {
$element.css(newStyle);
}
context.oldStyle = newStyle;
vnode.state.oldStyle = newStyle;
}
const classNames = ['PostStreamScrubber', 'Dropdown'];
if (this.props.className) classNames.push(this.props.className);
if (this.attrs.className) classNames.push(this.attrs.className);
return (
<div className={classNames.join(' ')}>
@@ -68,12 +70,12 @@ export default class PostStreamScrubber extends Component {
<div className="Scrubber-bar" />
<div className="Scrubber-info">
<strong>{viewing}</strong>
<span className="Scrubber-description">{this.stream.description}</span>
<span className="Scrubber-description"></span>
</div>
</div>
<div className="Scrubber-after" />
<div className="Scrubber-unread" config={styleUnread}>
<div className="Scrubber-unread" oncreate={styleUnread} onupdate={styleUnread}>
{app.translator.trans('core.forum.post_scrubber.unread_text', { count: unreadCount })}
</div>
</div>
@@ -87,11 +89,12 @@ export default class PostStreamScrubber extends Component {
);
}
config(isInitialized, context) {
onupdate() {
this.stream.loadPromise.then(() => this.updateScrubberValues({ animate: true, forceHeightChange: true }));
if (isInitialized) return;
}
context.onunload = this.ondestroy.bind(this);
oncreate(vnode) {
super.oncreate(vnode);
// Whenever the window is resized, adjust the height of the scrollbar
// so that it fills the height of the sidebar.
@@ -133,6 +136,15 @@ export default class PostStreamScrubber extends Component {
.on('mouseup touchend', (this.handlers.onmouseup = this.onmouseup.bind(this)));
setTimeout(() => this.scrollListener.start());
this.updateScrubberValues({ animate: true, forceHeightChange: true });
}
onremove() {
this.scrollListener.stop();
$(window).off('resize', this.handlers.onresize);
$(document).off('mousemove touchmove', this.handlers.onmousemove).off('mouseup touchend', this.handlers.onmouseup);
}
/**
@@ -196,13 +208,6 @@ export default class PostStreamScrubber extends Component {
this.updateScrubberValues({ animate: true, forceHeightChange: true });
}
ondestroy() {
this.scrollListener.stop();
$(window).off('resize', this.handlers.onresize);
$(document).off('mousemove touchmove', this.handlers.onmousemove).off('mouseup touchend', this.handlers.onmouseup);
}
onresize() {
// Adjust the height of the scrollbar so that it fills the height of
// the sidebar and doesn't overlap the footer.

Some files were not shown because too many files have changed in this diff Show More