Fix digest note review (#383)

* Fix issue where unreviewed note is displayed as reviewed

* Show unreviewed first

* Fix menu alignment on mobile

* Show completion message

* Display help on empty digest list

* Fix email type
This commit is contained in:
Sung Won Cho 2020-01-07 16:03:45 +11:00 committed by GitHub
commit ea51514c85
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 330 additions and 67 deletions

View file

@ -22,6 +22,7 @@ import (
"fmt"
"github.com/dnote/dnote/pkg/server/database"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
)
@ -179,3 +180,27 @@ func (a *App) GetDigests(p GetDigestsParam) ([]database.Digest, error) {
return ret, nil
}
// PreloadDigest preloads associations for the given digest. It returns a new digest.
func (a *App) PreloadDigest(d database.Digest) (database.Digest, error) {
var ret database.Digest
conn := a.DB.Where("id = ?", d.ID).
Preload("Notes", func(db *gorm.DB) *gorm.DB {
return db.Order("notes.created_at DESC")
}).
Preload("Notes.Book").
Preload("Notes.NoteReview", func(db *gorm.DB) *gorm.DB {
return db.Where("note_reviews.digest_id = ?", d.ID)
}).
Preload("Rule").
Preload("Receipts", func(db *gorm.DB) *gorm.DB {
return db.Where("digest_receipts.user_id = ?", d.UserID)
}).First(&ret)
if err := conn.Error; err != nil {
return ret, errors.Wrap(err, "preloading")
}
return ret, nil
}

View file

@ -51,7 +51,7 @@ func createEmailPreference(user database.User, tx *gorm.DB) error {
func createDefaultRepetitionRule(user database.User, tx *gorm.DB) error {
r := database.RepetitionRule{
Title: "Default repetition - all book",
Title: "Default repetition - all books",
UserID: user.ID,
Enabled: false,
Hour: 20,

View file

@ -29,23 +29,9 @@ import (
"github.com/dnote/dnote/pkg/server/log"
"github.com/dnote/dnote/pkg/server/presenters"
"github.com/gorilla/mux"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
)
func preloadDigest(conn *gorm.DB, userID int) *gorm.DB {
return conn.
Preload("Notes", func(db *gorm.DB) *gorm.DB {
return db.Order("notes.created_at DESC")
}).
Preload("Notes.Book").
Preload("Notes.NoteReview").
Preload("Rule").
Preload("Receipts", func(db *gorm.DB) *gorm.DB {
return db.Where("digest_receipts.user_id = ?", userID)
})
}
func (a *API) getDigest(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {
@ -56,17 +42,18 @@ func (a *API) getDigest(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
digestUUID := vars["digestUUID"]
db := a.App.DB
var digest database.Digest
conn := db.Where("user_id = ? AND uuid = ? ", user.ID, digestUUID)
conn = preloadDigest(conn, user.ID)
conn = conn.First(&digest)
if conn.RecordNotFound() {
d, err := a.App.GetUserDigestByUUID(user.ID, digestUUID)
if d == nil {
RespondNotFound(w)
return
} else if err := conn.Error; err != nil {
}
if err != nil {
HandleError(w, "finding digest", err, http.StatusInternalServerError)
return
}
digest, err := a.App.PreloadDigest(*d)
if err != nil {
HandleError(w, "finding digest", err, http.StatusInternalServerError)
return
}

View file

@ -158,7 +158,7 @@ func (c Context) notify(now time.Time, user database.User, digest database.Diges
return errors.Wrap(err, "getting sender email")
}
if err := c.EmailBackend.Queue(subject, sender, []string{account.Email.String}, mailer.EmailKindHTML, body); err != nil {
if err := c.EmailBackend.Queue(subject, sender, []string{account.Email.String}, mailer.EmailKindText, body); err != nil {
return errors.Wrap(err, "queueing email")
}

View file

@ -75,6 +75,7 @@ interface MenuProps {
triggerClassName?: string;
contentClassName?: string;
alignment: Alignment;
alignmentMd?: Alignment;
direction: Direction;
headerContent?: React.ReactNode;
wrapperClassName?: string;
@ -93,6 +94,7 @@ const Menu: React.FunctionComponent<MenuProps> = ({
triggerClassName,
contentClassName,
alignment,
alignmentMd,
direction,
headerContent,
wrapperClassName,
@ -181,6 +183,7 @@ const Menu: React.FunctionComponent<MenuProps> = ({
isOpen={isOpen}
setIsOpen={setIsOpen}
alignment={alignment}
alignmentMd={alignmentMd}
direction={direction}
renderContent={() => {
return (

View file

@ -34,8 +34,8 @@ interface Props {
headerText: string;
menuId: string;
alignment: Alignment;
alignmentMd?: Alignment;
direction: Direction;
disabled?: boolean;
wrapperClassName?: string;
triggerClassName?: string;
@ -53,6 +53,7 @@ const SelectMenu: React.FunctionComponent<Props> = ({
triggerId,
menuId,
alignment,
alignmentMd,
direction,
wrapperClassName,
triggerClassName
@ -82,6 +83,7 @@ const SelectMenu: React.FunctionComponent<Props> = ({
contentClassName={styles.content}
wrapperClassName={wrapperClassName}
alignment={alignment}
alignmentMd={alignmentMd}
direction={direction}
/>
);

View file

@ -37,19 +37,66 @@
}
}
/* alignment */
.left-align {
left: 0;
right: auto;
top: auto;
bottom: auto;
}
.right-align {
right: 0;
left: auto;
top: auto;
bottom: auto;
}
.top-align {
top: 0;
left: auto;
right: auto;
bottom: auto;
}
.bottom-align {
bottom: 0;
left: auto;
right: auto;
top: auto;
}
// md
.left-align-md {
@include breakpoint(md) {
left: 0;
right: auto;
top: auto;
bottom: auto;
}
}
.right-align-md {
@include breakpoint(md) {
right: 0;
left: auto;
top: auto;
bottom: auto;
}
}
.top-align-md {
@include breakpoint(md) {
top: 0;
left: auto;
right: auto;
bottom: auto;
}
}
.bottom-align-md {
@include breakpoint(md) {
bottom: 0;
left: auto;
right: auto;
top: auto;
}
}
/* direction */
.top-direction {
bottom: calc(100% + 4px);
}

View file

@ -32,6 +32,7 @@ interface Props {
closeOnEscapeKeydown?: boolean;
contentClassName?: string;
alignment?: Alignment;
alignmentMd?: Alignment;
triggerEl?: HTMLElement;
wrapperEl?: any;
hasBorder?: boolean;
@ -45,6 +46,7 @@ const PopoverContent: React.FunctionComponent<Props> = ({
wrapperEl,
children,
alignment,
alignmentMd,
direction,
hasBorder,
closeOnOutsideClick,
@ -135,6 +137,12 @@ const PopoverContent: React.FunctionComponent<Props> = ({
[styles['right-align']]: alignment === 'right',
[styles['top-align']]: alignment === 'top',
[styles['bottom-align']]: alignment === 'bottom',
[styles['left-align-md']]: alignmentMd === 'left',
[styles['right-align-md']]: alignmentMd === 'right',
[styles['top-align-md']]: alignmentMd === 'top',
[styles['bottom-align-md']]: alignmentMd === 'bottom',
// direction
[styles['top-direction']]: direction === 'top',
[styles['bottom-direction']]: direction === 'bottom',

View file

@ -20,7 +20,7 @@ import React, { useRef } from 'react';
import classnames from 'classnames';
import PopoverContent from './PopoverContent';
import styles from './Popover.module.scss';
import styles from './Popover.scss';
import { Alignment, Direction } from './types';
interface Props {
@ -29,6 +29,7 @@ interface Props {
renderTrigger: (any) => React.ReactNode;
renderContent: () => any;
alignment: Alignment;
alignmentMd?: Alignment;
direction: Direction;
contentHasBorder?: boolean;
hasArrow?: boolean;
@ -46,6 +47,7 @@ const Popover: React.FunctionComponent<Props> = ({
isOpen,
setIsOpen,
alignment,
alignmentMd,
direction,
renderTrigger,
contentId,
@ -83,6 +85,7 @@ const Popover: React.FunctionComponent<Props> = ({
wrapperEl={wrapperRef.current}
triggerEl={triggerRef.current}
alignment={alignment}
alignmentMd={alignmentMd}
direction={direction}
contentId={contentId}
hasBorder={contentHasBorder}

View file

@ -0,0 +1,29 @@
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
@import '../App/responsive';
@import '../App/theme';
@import '../App/variables';
@import '../App/font';
@import '../App/rem';
.wrapper {
padding: rem(40px) rem(16px);
text-align: center;
color: $gray;
}

View file

@ -0,0 +1,22 @@
import React from 'react';
import { SearchParams, Status } from './types';
import styles from './Empty.scss';
interface Props {
params: SearchParams;
}
const Empty: React.FunctionComponent<Props> = ({ params }) => {
if (params.status === Status.Unreviewed) {
return (
<div className={styles.wrapper}>
You have completed reviewing this digest.
</div>
);
}
return <div className={styles.wrapper}>No results matched your filters.</div>;
};
export default Empty;

View file

@ -26,11 +26,14 @@ import { useDispatch } from '../../store';
import { setDigestNoteReviewed } from '../../store/digest';
import Placeholder from '../Common/Note/Placeholder';
import NoteItem from './NoteItem';
import Empty from './Empty';
import { SearchParams } from './types';
import styles from './Digest.scss';
interface Props {
notes: DigestNoteData[];
digest: DigestData;
params: SearchParams;
isFetched: boolean;
isFetching: boolean;
}
@ -38,6 +41,7 @@ interface Props {
const NoteList: React.FunctionComponent<Props> = ({
isFetched,
isFetching,
params,
notes,
digest
}) => {
@ -62,6 +66,10 @@ const NoteList: React.FunctionComponent<Props> = ({
return null;
}
if (notes.length === 0) {
return <Empty params={params} />;
}
return (
<div className={styles.wrapper}>
<Helmet>

View file

@ -46,7 +46,7 @@ const StatusMenu: React.FunctionComponent<Props> = ({
const options = [
{
name: 'all',
name: 'unreviewed',
value: (
<Link
role="menuitem"
@ -57,26 +57,6 @@ const StatusMenu: React.FunctionComponent<Props> = ({
}}
ref={optRefs[0]}
tabIndex={-1}
>
All
</Link>
)
},
{
name: 'unreviewed',
value: (
<Link
role="menuitem"
className={selectMenuStyles.link}
to={getDigestPath(digestUUID, {
...searchObj,
status: Status.Unreviewed
})}
onClick={() => {
setIsOpen(false);
}}
ref={optRefs[1]}
tabIndex={-1}
>
Unreviewed
</Link>
@ -95,28 +75,48 @@ const StatusMenu: React.FunctionComponent<Props> = ({
onClick={() => {
setIsOpen(false);
}}
ref={optRefs[2]}
ref={optRefs[1]}
tabIndex={-1}
>
Reviewed
</Link>
)
},
{
name: 'all',
value: (
<Link
role="menuitem"
className={selectMenuStyles.link}
to={getDigestPath(digestUUID, {
...searchObj,
status: Status.All
})}
onClick={() => {
setIsOpen(false);
}}
ref={optRefs[2]}
tabIndex={-1}
>
All
</Link>
)
}
];
const isActive = status === Status.Reviewed || status === Status.Unreviewed;
const isActive = status === Status.Reviewed || status === Status.All;
let defaultCurrentOptionIdx: number;
let statusText: string;
if (status === Status.Reviewed) {
defaultCurrentOptionIdx = 2;
statusText = 'Reviewed';
} else if (status === Status.Unreviewed) {
defaultCurrentOptionIdx = 1;
statusText = 'Unreviewed';
statusText = 'Reviewed';
} else if (status === Status.All) {
defaultCurrentOptionIdx = 2;
statusText = 'All';
} else {
defaultCurrentOptionIdx = 0;
statusText = 'All';
statusText = 'Unreviewed';
}
return (
@ -134,8 +134,9 @@ const StatusMenu: React.FunctionComponent<Props> = ({
triggerClassName={classnames('button-no-padding', {
[styles['active-menu-trigger']]: isActive
})}
triggerText={` Status: ${statusText} `}
alignment="right"
triggerText={`Status: ${statusText}`}
alignment="left"
alignmentMd="right"
direction="bottom"
/>
);

View file

@ -79,24 +79,22 @@ function getNotes(notes: DigestNoteData[], p: SearchParams): DigestNoteData[] {
});
}
const statusMap = {
[Status.All]: Status.All,
[Status.Reviewed]: Status.Reviewed,
[Status.Unreviewed]: Status.Unreviewed
};
const sortMap = {
[Sort.Newest]: Sort.Newest,
[Sort.Oldest]: Sort.Oldest
};
function parseSearchParams(location: Location): SearchParams {
const searchObj = parseSearchString(location.search);
let sort: Sort;
if (searchObj.sort === Sort.Oldest) {
sort = Sort.Oldest;
} else {
sort = Sort.Newest;
}
let status: Status;
if (searchObj.status === Status.Unreviewed) {
status = Status.Unreviewed;
} else if (searchObj.status === Status.Reviewed) {
status = Status.Reviewed;
} else {
status = Status.All;
}
const status = statusMap[searchObj.status] || Status.Unreviewed;
const sort = sortMap[searchObj.sort] || Sort.Newest;
return {
sort,
@ -153,6 +151,7 @@ const Digest: React.FunctionComponent<Props> = ({ location, match }) => {
<div className="container mobile-nopadding">
<NoteList
digest={digest.data}
params={params}
notes={notes}
isFetched={digest.isFetched}
isFetching={digest.isFetching}

View file

@ -24,9 +24,9 @@ export enum Sort {
// Status is a set of possible values for status query parameters
export enum Status {
All = '',
Unreviewed = 'unreviewed',
Reviewed = 'reviewed'
Unreviewed = '',
Reviewed = 'reviewed',
All = 'all'
}
export interface SearchParams {

View file

@ -0,0 +1,31 @@
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
@import '../App/responsive';
@import '../App/theme';
@import '../App/variables';
@import '../App/font';
@import '../App/rem';
.flash {
margin-top: rem(20px);
@include breakpoint(lg) {
margin-top: 0;
}
}

View file

@ -0,0 +1,41 @@
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
@import '../App/responsive';
@import '../App/theme';
@import '../App/variables';
@import '../App/font';
@import '../App/rem';
.wrapper {
padding: rem(40px) rem(16px);
text-align: center;
color: $gray;
}
.support {
margin-top: rem(20px);
}
.md-support {
display: none;
@include breakpoint(md) {
display: block;
}
}

View file

@ -0,0 +1,26 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { getRepetitionsPath } from 'web/libs/paths';
import styles from './Empty.scss';
interface Props {}
const Empty: React.FunctionComponent<Props> = () => {
return (
<div className={styles.wrapper}>
<h3>No digests were found.</h3>
<p className={styles.support}>
You could <Link to={getRepetitionsPath()}>create repetition rules</Link>{' '}
first.
</p>
<p className={styles['md-support']}>
Digests are automatically created based on your repetition rules.
</p>
</div>
);
};
export default Empty;

View file

@ -22,6 +22,7 @@ import classnames from 'classnames';
import { DigestData } from 'jslib/operations/types';
import { getRange } from 'jslib/helpers/arr';
import Item from './Item';
import Empty from './Empty';
import Placeholder from './Placeholder';
import styles from './List.scss';
@ -49,6 +50,10 @@ const List: React.FunctionComponent<Props> = ({
return null;
}
if (items.length === 0) {
return <Empty />;
}
return (
<ul className={classnames('list-unstyled', styles.wrapper)}>
{items.map(item => {

View file

@ -17,10 +17,11 @@
*/
import React, { useEffect } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { Link, RouteComponentProps } from 'react-router-dom';
import Helmet from 'react-helmet';
import { usePrevious } from 'web/libs/hooks';
import { getSubscriptionPath } from 'web/libs/paths';
import { parseSearchString } from 'jslib/helpers/url';
import { useDispatch, useSelector } from '../../store';
import { getDigests } from '../../store/digests';
@ -28,6 +29,7 @@ import { Status } from './types';
import Flash from '../Common/Flash';
import List from './List';
import Toolbar from './Toolbar';
import styles from './Digests.scss';
function useFetchDigests(params: { page: number; status: Status }) {
const dispatch = useDispatch();
@ -48,9 +50,10 @@ function useFetchDigests(params: { page: number; status: Status }) {
interface Props extends RouteComponentProps {}
const Digests: React.FunctionComponent<Props> = ({ location }) => {
const { digests } = useSelector(state => {
const { user, digests } = useSelector(state => {
return {
digests: state.digests
digests: state.digests,
user: state.auth.user.data
};
});
const { page, status } = parseSearchString(location.search);
@ -69,10 +72,21 @@ const Digests: React.FunctionComponent<Props> = ({ location }) => {
<div className="page-header">
<h1 className="page-heading">Digests</h1>
</div>
</div>
<Flash kind="danger" when={Boolean(digests.errorMessage)}>
<div className="container mobile-nopadding">
<Flash
kind="danger"
when={Boolean(digests.errorMessage)}
wrapperClassName={styles.flash}
>
Error getting digests: {digests.errorMessage}
</Flash>
<Flash when={!user.pro} kind="warning" wrapperClassName={styles.flash}>
Digests are not enabled on your plan.{' '}
<Link to={getSubscriptionPath()}>Upgrade here.</Link>
</Flash>
</div>
<div className="container mobile-nopadding">

View file

@ -77,12 +77,16 @@ const Content: React.FunctionComponent = () => {
</div>
<div className="container mobile-nopadding">
<Flash when={!user.pro} kind="warning">
<Flash when={!user.pro} kind="warning" wrapperClassName={styles.flash}>
Repetitions are not enabled on your plan.{' '}
<Link to={getSubscriptionPath()}>Upgrade here.</Link>
</Flash>
<Flash when={user.pro && !user.emailVerified} kind="warning">
<Flash
when={user.pro && !user.emailVerified}
kind="warning"
wrapperClassName={styles.flash}
>
Please verify your email address in order to receive digests.{' '}
<Link to={getSettingsPath(SettingSections.account)}>
Go to settings.

View file

@ -30,3 +30,11 @@
.content {
margin-top: rem(20px);
}
.flash {
margin-top: rem(20px);
@include breakpoint(lg) {
margin-top: 0;
}
}