diff --git a/client/index.html b/client/index.html index 043df9d..ed29931 100644 --- a/client/index.html +++ b/client/index.html @@ -8,29 +8,35 @@ {% raw micro_dependencies() %} + +
  • + Introduction +
  • + {% raw micro_boot() %} + {% raw micro_templates() %} - 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