@@ -335,7 +334,7 @@
Settings
-
+
@@ -374,6 +373,15 @@
Trashed items
+
+
diff --git a/client/listling/components/start.js b/client/listling/components/start.js
new file mode 100644
index 0000000..7f1e5b4
--- /dev/null
+++ b/client/listling/components/start.js
@@ -0,0 +1,107 @@
+/*
+ * Open Listling
+ * Copyright (C) 2018 Open Listling contributors
+ *
+ * This program 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.
+ *
+ * This program 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 this program.
+ * If not, see
.
+ */
+
+/** Start page. */
+
+"use strict";
+
+self.listling = self.listling || {};
+listling.components = listling.components || {};
+listling.components.start = {};
+
+/** Create a :ref:`List` for the given *useCase* and open it. */
+listling.components.start.createList = async function(useCase) {
+ try {
+ const list = await ui.call("POST", "/api/lists", {use_case: useCase, v: 2});
+ ui.navigate(`/lists/${list.id.split(":")[1]}`).catch(micro.util.catch);
+ } catch (e) {
+ ui.handleCallError(e);
+ }
+};
+
+/** Return available list use cases. */
+listling.components.start.getUseCases = function() {
+ return [
+ {id: "todo", title: "To-Do list", icon: "check"},
+ {id: "shopping", title: "Shopping list", icon: "shopping-cart"},
+ {id: "meeting-agenda", title: "Meeting agenda", icon: "handshake"},
+ ...ui.mapServiceKey ? [{id: "map", title: "Map", icon: "map"}] : [],
+ {id: "simple", title: "Simple list", icon: "list"}
+ ];
+};
+
+/** Start page. */
+listling.components.start.StartPage = class extends micro.Page {
+ static async make() {
+ const lists = await micro.call("GET", `/api/users/${ui.user.id}/lists`);
+ if (lists.count === 0) {
+ return document.createElement("listling-intro-page");
+ }
+ const page = document.createElement("listling-start-page");
+ page.lists = lists;
+ return page;
+ }
+
+ createdCallback() {
+ super.createdCallback();
+ this.appendChild(
+ document.importNode(ui.querySelector("#listling-start-page-template").content, true)
+ );
+ this._data = new micro.bind.Watchable({
+ user: ui.user,
+ lists: null,
+ useCases: listling.components.start.getUseCases(),
+ createList: listling.components.start.createList,
+ makeListURL: listling.util.makeListURL,
+
+ onKeyDown: event => {
+ if (event.currentTarget === event.target && event.key === "Enter") {
+ event.currentTarget.firstElementChild.click();
+ }
+ },
+
+ remove: async list => {
+ try {
+ await ui.call("DELETE", `/api/users/${ui.user.id}/lists/${list.id}`);
+ } catch (e) {
+ if (e instanceof micro.APIError && e.error.__type__ === "NotFoundError") {
+ // Continue as normal if the list has already been removed
+ } else {
+ ui.handleCallError(e);
+ return;
+ }
+ }
+ const i = this._data.lists.items.findIndex(l => l.id === list.id);
+ this._data.lists.items.splice(i, 1);
+ if (this._data.lists.items.length === 0) {
+ ui.navigate("/intro").catch(micro.util.catch);
+ }
+ }
+ });
+ micro.bind.bind(this.children, this._data);
+ }
+
+ /** :ref:`Lists` of the user. */
+ get lists() {
+ return this._data.lists;
+ }
+
+ set lists(value) {
+ this._data.lists = Object.assign({}, value, {items: new micro.bind.Watchable(value.items)});
+ }
+};
+
+document.registerElement("listling-start-page", listling.components.start.StartPage);
diff --git a/client/listling/index.js b/client/listling/index.js
index 3862f32..7a34795 100644
--- a/client/listling/index.js
+++ b/client/listling/index.js
@@ -34,7 +34,8 @@ listling.UI = class extends micro.UI {
}
this.pages = this.pages.concat([
- {url: "^/$", page: "listling-start-page"},
+ {url: "^/$", page: listling.components.start.StartPage.make},
+ {url: "^/intro$", page: "listling-intro-page"},
{url: "^/about$", page: makeAboutPage},
{url: "^/lists/([^/]+)(?:/[^/]+)?$", page: listling.ListPage.make}
]);
@@ -51,25 +52,19 @@ listling.UI = class extends micro.UI {
};
/**
- * Start page.
+ * Intro page.
*/
-listling.StartPage = class extends micro.Page {
+listling.IntroPage = class extends micro.Page {
createdCallback() {
- const USE_CASES = [
- {id: "todo", title: "To-Do list", icon: "check"},
- {id: "shopping", title: "Shopping list", icon: "shopping-cart"},
- {id: "meeting-agenda", title: "Meeting agenda", icon: "handshake"},
- ...ui.mapServiceKey ? [{id: "map", title: "Map", icon: "map"}] : [],
- {id: "simple", title: "Simple list", icon: "list"}
- ];
-
super.createdCallback();
+ const useCases = listling.components.start.getUseCases();
this.appendChild(
- document.importNode(ui.querySelector(".listling-start-page-template").content, true));
+ document.importNode(ui.querySelector(".listling-intro-page-template").content, true));
this._data = new micro.bind.Watchable({
settings: ui.settings,
- useCases: USE_CASES,
- selectedUseCase: USE_CASES[0],
+ useCases,
+ selectedUseCase: useCases[0],
+ createList: listling.components.start.createList,
focusUseCase(event) {
event.target.focus();
@@ -84,15 +79,6 @@ listling.StartPage = class extends micro.Page {
}, 0);
},
- createList: async useCase => {
- try {
- const list = await ui.call("POST", "/api/lists", {use_case: useCase.id, v: 2});
- ui.navigate(`/lists/${list.id.split(":")[1]}`).catch(micro.util.catch);
- } catch (e) {
- ui.handleCallError(e);
- }
- },
-
createExample: async useCase => {
try {
const list = await ui.call(
@@ -110,14 +96,15 @@ listling.StartPage = class extends micro.Page {
attachedCallback() {
super.attachedCallback();
ui.shortcutContext.add("S", () => {
- this.querySelector(".listling-selected .listling-start-create-list").click();
+ this.querySelector(".listling-selected .listling-intro-create-list").click();
});
ui.shortcutContext.add("E", () => {
if (this._data.selectedUseCase.id !== "simple") {
- this.querySelector(".listling-selected .listling-start-create-example button")
+ this.querySelector(".listling-selected .listling-intro-create-example button")
.click();
}
});
+ ui.url = "/intro";
}
detachedCallback() {
@@ -328,6 +315,17 @@ listling.ListPage = class extends micro.Page {
);
}
})().catch(micro.util.catch));
+
+ // Add list to lists of user
+ (async() => {
+ try {
+ await ui.call(
+ "POST", `/api/users/${ui.user.id}/lists`, {list_id: this._data.lst.id}
+ );
+ } catch (e) {
+ ui.handleCallError(e);
+ }
+ })().catch(micro.util.catch);
}
detachedCallback() {
@@ -559,7 +557,7 @@ listling.ItemElement = class extends HTMLLIElement {
};
document.registerElement("listling-ui", {prototype: listling.UI.prototype, extends: "body"});
-document.registerElement("listling-start-page", listling.StartPage);
+document.registerElement("listling-intro-page", listling.IntroPage);
document.registerElement("listling-list-page", listling.ListPage);
document.registerElement("listling-item",
{prototype: listling.ItemElement.prototype, extends: "li"});
diff --git a/client/package.json b/client/package.json
index bb79cd7..744c38b 100644
--- a/client/package.json
+++ b/client/package.json
@@ -5,7 +5,7 @@
"clean": "rm -rf node_modules"
},
"dependencies": {
- "@noyainrain/micro": "^0.36"
+ "@noyainrain/micro": "^0.37"
},
"devDependencies": {
"eslint": "~5.15",
diff --git a/client/tests/ui_test.js b/client/tests/ui_test.js
index 9eed386..9e2fe6f 100644
--- a/client/tests/ui_test.js
+++ b/client/tests/ui_test.js
@@ -38,8 +38,9 @@ describe("UI", function() {
this.timeout(5 * 60 * 1000);
async function createExampleList() {
- await browser.findElement({css: ".micro-ui-logo"}).click();
- await browser.findElement({css: ".listling-start-create-example button"}).click();
+ await browser.findElement({css: ".micro-ui-header-menu"}).click();
+ await browser.findElement({css: ".listling-ui-intro"}).click();
+ await browser.findElement({css: ".listling-intro-create-example button"}).click();
await browser.wait(until.elementLocated({css: "listling-list-page"}));
}
@@ -65,15 +66,30 @@ describe("UI", function() {
let input;
let itemMenu;
- // View start page
+ // View intro page
await getWithServiceWorker(browser, `${URL}/`);
await browser.wait(
untilElementTextLocated({css: ".micro-logo"}, "My Open Listling"), timeout);
// Create list
- await browser.findElement({css: ".listling-start-create-list"}).click();
+ await browser.findElement({css: ".listling-intro-create-list"}).click();
await browser.wait(
- untilElementTextLocated({css: "listling-list-page h1"}, "New to-do list"), timeout);
+ untilElementTextLocated({css: "listling-list-page h1"}, "New to-do list"), timeout
+ );
+
+ // View start page
+ await browser.findElement({css: ".micro-ui-logo"}).click();
+ await browser.wait(
+ untilElementTextLocated({css: ".listling-start-lists .link"}, "New to-do list"), timeout
+ );
+
+ // Create list
+ await browser.findElement({css: ".listling-start-create"}).click();
+ await browser.findElement({css: ".listling-start-create [is=micro-menu] li:last-child"})
+ .click();
+ await browser.wait(
+ untilElementTextLocated({css: "listling-list-page h1"}, "New list"), timeout
+ );
// Create example list
await createExampleList();
@@ -91,6 +107,8 @@ describe("UI", function() {
await form.findElement({css: "button:not([type])"}).click();
await browser.wait(
untilElementTextLocated({css: "listling-list-page h1"}, "Cat colony tasks"));
+ // Work around Edge not firing blur event when a button gets disabled
+ await browser.findElement({css: ".listling-list-menu"}).click();
// Create item
await browser.findElement({css: ".listling-list-create-item button"}).click();
@@ -148,7 +166,7 @@ describe("UI", function() {
it("should work for staff", async function() {
await browser.get(`${URL}/`);
- await browser.wait(until.elementLocated({css: "listling-start-page"}), timeout);
+ await browser.wait(until.elementLocated({css: "listling-intro-page"}), timeout);
await createExampleList();
// View activity page
diff --git a/doc/conf.py b/doc/conf.py
index 242f1b7..987b510 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -11,7 +11,7 @@
project = 'Open Listling'
copyright = '2018 Open Listling contributors'
-version = release = '0.13.0'
+version = release = '0.14.0'
html_theme_options = {
'logo': 'listling.svg',
diff --git a/doc/pythonapi.rst b/doc/pythonapi.rst
index 3dfad53..79eb0bb 100644
--- a/doc/pythonapi.rst
+++ b/doc/pythonapi.rst
@@ -5,7 +5,7 @@ listling
--------
.. automodule:: listling
- :members: Listling, List, Item
+ :members: Listling, User, List, Item
server
------
diff --git a/doc/webapi.rst b/doc/webapi.rst
index cb94978..be2e5f4 100644
--- a/doc/webapi.rst
+++ b/doc/webapi.rst
@@ -47,6 +47,53 @@ Lists
Get the :ref:`List` given by *id*.
+.. _User:
+
+User
+----
+
+Listling user.
+
+.. include:: micro/user-attributes.inc
+
+.. describe:: lists
+
+ :ref:`UserLists` of the user.
+
+.. include:: micro/user-endpoints.inc
+
+.. _UserLists:
+
+Lists
+^^^^^
+
+:ref:`List` :ref:`Collection` of the user, ordered by time added, latest first.
+
+Lists created by the user are added automatically.
+
+.. include:: micro/collection-attributes.inc
+
+.. include:: micro/collection-endpoints.inc
+
+.. http:post:: /users/(id)/lists
+
+ ``{"list_id"}``
+
+ Add the list with *list_id*.
+
+ If the list is already in the collection, the associated time is updated. If there is no list
+ with *list_id*, a :ref:`ValueError` is returned.
+
+ Permission: The user oneself.
+
+.. http:delete:: /users/(id)/lists/(list-id)
+
+ Remove the list with *list-id*.
+
+ If the user is the list owner, a :ref:`ValueError` is returned.
+
+ Permission: The user oneself.
+
.. _Settings:
Settings
@@ -96,6 +143,10 @@ List
.. [1] Edit the list and create and move items
.. [2] Edit, trash, restore, check and uncheck items
+.. describe:: items
+
+ List :ref:`Items`.
+
.. include:: micro/editable-endpoints.inc
.. _Items:
diff --git a/listling/__init__.py b/listling/__init__.py
index 5016eca..fb3f398 100644
--- a/listling/__init__.py
+++ b/listling/__init__.py
@@ -14,4 +14,4 @@
"""Web app for collaboratively composing lists."""
-from .listling import Item, List, Listling
+from .listling import Item, List, Listling, User
diff --git a/listling/listling.py b/listling/listling.py
index 8d9ed40..f63f333 100644
--- a/listling/listling.py
+++ b/listling/listling.py
@@ -14,12 +14,16 @@
"""Open Listling core."""
+from time import time
+
import micro
from micro import (Activity, Application, Collection, Editable, Location, Object, Orderable,
Trashable, Settings, Event, WithContent)
from micro.jsonredis import JSONRedis
from micro.util import randstr, run_instant, str_or_none, ON
+from micro.jsonredis import RedisSortedSet
+
_USE_CASES = {
'simple': {'title': 'New list', 'features': []},
'todo': {'title': 'New to-do list', 'features': ['check']},
@@ -117,6 +121,7 @@ def create(self, use_case=None, description=None, title=None, v=1):
activity=Activity('{}.activity'.format(id), self.app, subscriber_ids=[]))
self.app.r.oset(lst.id, lst)
self.app.r.rpush(self.map_key, lst.id)
+ self.app.user.lists.add(lst, user=self.app.user)
self.app.activity.publish(
Event.create('create-list', None, {'lst': lst}, app=self.app))
return lst
@@ -154,13 +159,13 @@ def __init__(self, redis_url='', email='bot@localhost', smtp_url='',
render_email_auth_message=None, *, video_service_keys={}):
super().__init__(redis_url, email, smtp_url, render_email_auth_message,
video_service_keys=video_service_keys)
- self.types.update({'List': List, 'Item': Item})
+ self.types.update({'User': User, 'List': List, 'Item': Item})
self.lists = Listling.Lists((self, 'lists'))
def do_update(self):
version = self.r.get('version')
if not version:
- self.r.set('version', 6)
+ self.r.set('version', 7)
return
version = int(version)
@@ -215,6 +220,17 @@ def do_update(self):
r.omset({lst['id']: lst for lst in lists})
r.set('version', 6)
+ # Deprecated since 0.14.0
+ if version < 7:
+ now = time()
+ lists = r.omget(r.lrange('lists', 0, -1))
+ for lst in lists:
+ r.zadd('{}.lists'.format(lst['authors'][0]), {lst['id']: -now})
+ r.set('version', 7)
+
+ def create_user(self, data):
+ return User(**data)
+
def create_settings(self):
# pylint: disable=unexpected-keyword-arg; decorated
return Settings(
@@ -223,6 +239,55 @@ def create_settings(self):
provider_description={}, feedback_url=None, staff=[], push_vapid_private_key=None,
push_vapid_public_key=None, v=2)
+class User(micro.User):
+ """See :ref:`User`."""
+
+ class Lists(Collection):
+ """See :ref:`UserLists`."""
+ # We use setattr / getattr to work around a Pylint error for Generic classes (see
+ # https://github.com/PyCQA/pylint/issues/2443)
+
+ def __init__(self, user):
+ super().__init__(RedisSortedSet('{}.lists'.format(user.id), user.app.r), app=user.app)
+ setattr(self, 'user', user)
+
+ def add(self, lst, *, user):
+ """See: :http:post:`/users/(id)/lists`."""
+ if user != getattr(self, 'user'):
+ raise PermissionError()
+ self.app.r.zadd(self.ids.key, {lst.id: -time()})
+
+ def remove(self, lst, *, user):
+ """See :http:delete:`/users/(id)/lists/(list-id)`.
+
+ If *lst* is not in the collection, a :exc:`micro.error.ValueError` is raised.
+ """
+ if user != getattr(self, 'user'):
+ raise PermissionError()
+ if lst.authors[0] == getattr(self, 'user'):
+ raise micro.ValueError(
+ 'user {} is owner of lst {}'.format(getattr(self, 'user').id, lst.id))
+ if self.app.r.zrem(self.ids.key, lst.id) == 0:
+ raise micro.ValueError(
+ 'No lst {} in lists of user {}'.format(lst.id, getattr(self, 'user').id))
+
+ def read(self, *, user):
+ """Return collection for reading."""
+ if user != getattr(self, 'user'):
+ raise PermissionError()
+ return self
+
+ def __init__(self, **data):
+ super().__init__(**data)
+ self.lists = User.Lists(self)
+
+ def json(self, restricted=False, include=False):
+ return {
+ **super().json(restricted=restricted, include=include),
+ **({'lists': self.lists.json(restricted=restricted, include=include)}
+ if restricted and self.app.user == self else {})
+ }
+
class List(Object, Editable):
"""See :ref:`List`."""
@@ -309,7 +374,9 @@ def json(self, restricted=False, include=False):
'description': self.description,
'features': self.features,
'mode': self.mode,
- 'activity': self.activity.json(restricted)
+ 'activity': self.activity.json(restricted),
+ **({'items': self.items.json(restricted=restricted, include=include)} if restricted
+ else {}),
}
def _check_permission(self, user, op):
diff --git a/listling/server.py b/listling/server.py
index 7c061b6..2cdc27a 100644
--- a/listling/server.py
+++ b/listling/server.py
@@ -22,8 +22,8 @@
import micro
from micro import Location
-from micro.server import (Endpoint, Server, make_activity_endpoint, make_orderable_endpoints,
- make_trashable_endpoints)
+from micro.server import (Endpoint, CollectionEndpoint, Server, make_activity_endpoint,
+ make_orderable_endpoints, make_trashable_endpoints)
from micro.util import ON
from . import Listling
@@ -33,6 +33,8 @@ def make_server(*, port=8080, url=None, debug=False, redis_url='', smtp_url='',
"""Create an Open Listling server."""
app = Listling(redis_url, smtp_url=smtp_url, video_service_keys=video_service_keys)
handlers = [
+ (r'/api/users/([^/]+)/lists$', _UserListsEndpoint),
+ (r'/api/users/([^/]+)/lists/([^/]+)$', _UserListEndpoint),
(r'/api/lists$', _ListsEndpoint),
(r'/api/lists/create-example$', _ListsCreateExampleEndpoint),
(r'/api/lists/([^/]+)$', _ListEndpoint),
@@ -52,6 +54,29 @@ def make_server(*, port=8080, url=None, debug=False, redis_url='', smtp_url='',
client_shell=['listling.css', 'listling', 'images'],
client_map_service_key=client_map_service_key)
+class _UserListsEndpoint(CollectionEndpoint):
+ def initialize(self):
+ super().initialize(
+ get_collection=lambda id: self.app.users[id].lists.read(user=self.current_user))
+
+ def post(self, id):
+ args = self.check_args({'list_id': str})
+ list_id = args.pop('list_id')
+ try:
+ args['lst'] = self.app.lists[list_id]
+ except KeyError:
+ raise micro.ValueError('No list {}'.format(list_id))
+ lists = self.get_collection(id)
+ lists.add(**args, user=self.current_user)
+ self.write({})
+
+class _UserListEndpoint(Endpoint):
+ def delete(self, id, list_id):
+ lists = self.app.users[id].lists
+ lst = lists[list_id]
+ lists.remove(lst, user=self.current_user)
+ self.write({})
+
class _ListsEndpoint(Endpoint):
def post(self):
args = self.check_args({
diff --git a/listling/tests/test_listling.py b/listling/tests/test_listling.py
index 5a74089..fdccc0a 100644
--- a/listling/tests/test_listling.py
+++ b/listling/tests/test_listling.py
@@ -27,9 +27,14 @@
app = Listling(redis_url='15')
app.r.flushdb()
app.update()
+
app.login()
# Compatibility for missing todo use case (deprecated since 0.3.0)
app.lists.create_example('shopping')
+# Compatibility for title (deprecated since 0.3.0)
+app.lists.create('New list')
+app.login()
+app.lists.create('New list')
"""
class ListlingTestCase(AsyncTestCase):
@@ -45,6 +50,7 @@ def test_lists_create(self):
lst = self.app.lists.create(v=2)
self.assertEqual(lst.title, 'New list')
self.assertIn(lst.id, self.app.lists)
+ self.assertIn(lst.id, self.user.lists)
@gen_test
async def test_lists_create_example(self):
@@ -68,12 +74,12 @@ def test_update_db_fresh(self):
self.assertEqual(app.settings.title, 'My Open Listling')
def test_update_db_version_previous(self):
- self.setup_db('0.6.0')
+ self.setup_db('0.13.0')
app = Listling(redis_url='15')
app.update()
- lst = app.lists[0]
- self.assertEqual(lst.mode, 'collaborate')
+ user = app.settings.staff[0]
+ self.assertEqual(set(user.lists.values()), set(app.lists[0:2]))
def test_update_db_version_first(self):
self.setup_db('0.2.1')
@@ -93,6 +99,30 @@ def test_update_db_version_first(self):
self.assertIsNone(item.resource)
# Update to version 6
self.assertEqual(lst.mode, 'collaborate')
+ # Update to version 7
+ user = app.settings.staff[0]
+ self.assertEqual(set(user.lists.values()), set(app.lists[0:2]))
+
+class UserListsTest(ListlingTestCase):
+ def test_add(self):
+ shared_lst = self.app.lists.create(v=2)
+ user = self.app.login()
+ lst = self.app.lists.create(v=2)
+ user.lists.add(shared_lst, user=user)
+ self.assertEqual(list(user.lists.values()), [shared_lst, lst])
+
+ def test_remove(self):
+ shared_lst = self.app.lists.create(v=2)
+ user = self.app.login()
+ lst = self.app.lists.create(v=2)
+ user.lists.add(shared_lst, user=user)
+ user.lists.remove(shared_lst, user=user)
+ self.assertEqual(list(user.lists.values()), [lst])
+
+ def test_remove_as_list_owner(self):
+ lst = self.app.lists.create(v=2)
+ with self.assertRaisesRegex(ValueError, 'owner'):
+ self.user.lists.remove(lst, user=self.user)
class ListTest(ListlingTestCase):
def test_edit(self):
diff --git a/listling/tests/test_server.py b/listling/tests/test_server.py
index 1adb9c3..46952c5 100644
--- a/listling/tests/test_server.py
+++ b/listling/tests/test_server.py
@@ -14,6 +14,8 @@
# pylint: disable=missing-docstring; test module
+import json
+
from micro.test import ServerTestCase
from tornado.testing import gen_test
@@ -32,6 +34,13 @@ def setUp(self):
async def test_availibility(self):
lst = self.app.lists.create_example('todo')
item = next(iter(lst.items.values()))
+ self.app.login()
+ shared_lst = self.app.lists.create(v=2)
+ await self.request('/api/users/{}/lists'.format(self.client_user.id))
+ await self.request('/api/users/{}/lists'.format(self.client_user.id), method='POST',
+ body=json.dumps({'list_id': shared_lst.id}))
+ await self.request('/api/users/{}/lists/{}'.format(self.client_user.id, shared_lst.id),
+ method='DELETE')
await self.request('/api/lists', method='POST', body='{"v": 2}')
await self.request('/api/lists/create-example', method='POST',
body='{"use_case": "shopping"}')
diff --git a/requirements.txt b/requirements.txt
index 5f5c0d1..607d1de 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1 +1 @@
-noyainrain.micro ~= 0.36.0
+noyainrain.micro ~= 0.37.0