diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md
index 08f52b2da..6f9669cf3 100644
--- a/.github/ISSUE_TEMPLATE.md
+++ b/.github/ISSUE_TEMPLATE.md
@@ -1,8 +1,14 @@
-This issue tracker is **reserved** for bug reports and feature requests.
+This issue tracker is **reserved** for bug reports.
-The place to ask a question or call for help is at Fab-manager forums at https://forum.fab-manager.com/.
+The place to ask a question or call for help is at [Fab-manager forums](https://forum.fab-manager.com)
+
+The place to request or vote for new feature is on the [feedback website](https://feedback.fab-manager.com)
To report a bug, please describe:
- Expected behavior and actual behavior.
- Steps to reproduce the problem.
- Specifications like the version of the project, operating system, or hardware.
+
+The following elements may help to quickly resolve your issue:
+- Server logs `tail -f /apps/fabmanager/log/app-stdout.log` on the server
+- Client logs `Ctrl`+`Maj`+`i` > `Console` in the browser
diff --git a/3rd-PARTY-LICENSES.md b/3rd-PARTY-LICENSES.md
index e112be4c1..5fc887b81 100644
--- a/3rd-PARTY-LICENSES.md
+++ b/3rd-PARTY-LICENSES.md
@@ -1,5 +1,5 @@
-Fab-Manager uses some external components, which are licenced under the
+Fab-manager uses some external components, which are licenced under the
terms of the following licences:
- [Apache 2 license](http://www.apache.org/licenses/LICENSE-2.0):
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 784975ed6..1a3e3b895 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,78 @@
-# Changelog Fab Manager
+# Changelog Fab-manager
+
+## v4.3.0 2020 March 04
+
+- Ability to configure reservation slot restricted for plan subscribers
+- Ability to configure the policy (allow or prevent) for members booking a machine/formation/event slot, if they already have a reservation the same day at the same time
+- Ability to create and delete periodic calendar availabilities (recurrence)
+- Ability to fully customize the home page
+- Automated setup assistant
+- An administrator can delete a member
+- An event reservation can be cancelled, if reservation cancellation is enabled
+- Delete multiple recurring events at one time
+- Edit multiple recurring events at one time
+- Ability to import iCalendar agendas in the public calendar, through URLs to ICS files (RFC 5545)
+- Ability to configure the duration of a reservation slot, using `SLOT_DURATION`. Previously, only 60 minutes slots were allowed
+- Ability to force the email validation when a new user registers. This is optionally configured with `USER_CONFIRMATION_NEEDED_TO_SIGN_IN`
+- Display the scheduled events in the admin calendar, depending on `EVENTS_IN_CALENDAR` configuration.
+- Display indications on required fields in new administrator form
+- Administrators can to book machine/space/training slots, until 1 month in the past
+- Filter members by non-validated emails or by inactive for 3 years
+- Ability to customize the title of the link to the about page
+- Automatic version check with security alerts
+- Public endpoint to check the system health
+- Configuration of phone number in members registration forms: can be required or optional, depending on `PHONE_REQUIRED` configuration
+- Improved user experience in defining slots in the calendar management
+- Improved notification email to the member when a rolling subscription is taken
+- Notify all admins on the creation of a refund invoice
+- Helper links between admin sections of the scheduling process
+- Calendar management: improved legend display and visual behavior
+- Reorganized left menu
+- Create machine availabilities: select all/none in a click
+- Prevent event reservation in the past [Taiga#127]
+- Removed the need of twitter API keys to display the last tweet on the home page
+- Various helper links to help newcomers creating their first items
+- Handle Ctrl^C in upgrade scripts
+- Updated moment-timezone
+- Updated angular-ui-bootstrap from v0.14 to v1.2
+- Updated caxlsx to 3.0.1 and rails_axlsx to rails_caxlsx
+- Updated sidekiq to 5.2.8
+- Option to disable developers analytics
+- Added the a "cron" tab in Sidekiq web-ui to watch scheduled tasks
+- Integration of Crowdin "in-context" translation management system
+- Added freeCAD files as default allowed extensions
+- Rake task to sync local users with Stripe
+- Unified translations syntax to use ICU MessageFormat
+- Refactored front-end translations keys with unified paths
+- Updated and refactored README and documentations
+- Harmonized Fab-manager typography and case
+- Updated seeds file
+- Fix a bug: unable to remove the picture from a training
+- Fix a bug: no alerts on errors during admin creation
+- Fix a bug: replaces all Time.now by DateTime.current to prevent time zones issues [Taiga#134]
+- Fix a bug: logs are not printed in staging environment
+- Fix a bug: theme colors must be selected twice before the changes became effective
+- Fix a bug: datepicker does not work in profile completion screen
+- Fix a bug: unable to select a group in profile completion screen
+- Fix a bug: in some cases, bogus admin notification on profile completed
+- Fix a bug: with Firefox browser, the texts in date inputs are shifted to the bottom
+- Fix a bug: sometimes when browsing the invoices section, the translations are missing
+- Fix a bug: first day of week is ignored in agendas (#169)
+- Fix a bug: statistics page is bogus before the creation of the first plan
+- Fix a bug: default invoice logo is broken and prevent invoice generation
+- Fix a security issue: updated loofah to fix [CVE-2019-15587](https://nvd.nist.gov/vuln/detail/CVE-2019-15587)
+- Fix a security issue: updated angular to 1.7.9 to fix [CVE-2019-10768](https://nvd.nist.gov/vuln/detail/CVE-2019-10768)
+- Fix a security issue: updated puma to 3.12.4 to fix [GHSA-7xx3-m584-x994](https://github.com/advisories/GHSA-7xx3-m584-x994), [CVE-2020-5247](https://nvd.nist.gov/vuln/detail/CVE-2020-5247) and [CVE-2019-16254](https://nvd.nist.gov/vuln/detail/CVE-2020-5247)
+- Fix a security issue: updated nokogiri to 1.10.8 to fix [CVE-2020-7595](https://nvd.nist.gov/vuln/detail/CVE-2020-7595)
+- Fix a security issue: updated rack to 1.6.12 to fix [CVE-2019-16782](https://nvd.nist.gov/vuln/detail/CVE-2019-16782)
+- [TODO DEPLOY] add the `SLOT_DURATION` environment variable (see [doc/environment.md](doc/environment.md#SLOT_DURATION) for configuration details)
+- [TODO DEPLOY] add the `PHONE_REQUIRED` environment variable (see [doc/environment.md](doc/environment.md#PHONE_REQUIRED) for configuration details)
+- [TODO DEPLOY] add the `EVENTS_IN_CALENDAR` environment variable (see [doc/environment.md](doc/environment.md#EVENTS_IN_CALENDAR) for configuration details)
+- [TODO DEPLOY] add the `USER_CONFIRMATION_NEEDED_TO_SIGN_IN` environment variable (see [doc/environment.md](doc/environment.md#USER_CONFIRMATION_NEEDED_TO_SIGN_IN) for configuration details)
+- [TODO DEPLOY] add the `BOOK_SLOT_AT_SAME_TIME` environment variable (see [doc/environment.md](doc/environment.md#BOOK_SLOT_AT_SAME_TIME) for configuration details)
+- [TODO DEPLOY] -> (only dev) `bundle install && yarn install`
+- [TODO DEPLOY] `rake db:migrate && rake db:seed`
+- [TODO DEPLOY] `rake fablab:fix:name_stylesheet`
## v4.2.4 2019 October 30
@@ -58,6 +132,7 @@
- [TODO DEPLOY] add the `MAX_IMPORT_SIZE` environment variable (see [doc/environment.md](doc/environment.md#MAX_IMPORT_SIZE) for configuration details)
- [TODO DEPLOY] add the `FABLAB_WITHOUT_INVOICES` environment variable (see [doc/environment.md](doc/environment.md#FABLAB_WITHOUT_INVOICES) for configuration details)
- [TODO DEPLOY] add the `SMTP_TLS` environment variable (see [doc/environment.md](doc/environment.md#SMTP_TLS) for configuration details)
+- [TODO DEPLOY] add the `FABLAB_WITHOUT_WALLET` environment variable (see [doc/environment.md](doc/environment.md#FABLAB_WITHOUT_WALLET) for configuration details)
- [TODO DEPLOY] **IMPORTANT** Please read [postgres_upgrade.md](doc/postgres_upgrade.md) for instructions on upgrading PostgreSQL.
## v4.1.1 2019 September 20
@@ -91,7 +166,7 @@
## v4.0.4 2019 August 14
- Fix a bug: #140 VAT rate is erroneous in invoices.
- Note: this bug was introduced in v4.0.3 and requires (if you are on v4.0.3) to regenerate the invoices since August 1st (if
+ Note: this bug was introduced in v4.0.3 and requires (if you are on v4.0.3) to regenerate the invoices since August 1st
- [TODO DEPLOY] `rake fablab:maintenance:regenerate_invoices[2019,8]`
## v4.0.3 2019 August 01
@@ -135,7 +210,7 @@
- Refactored user's profile to keep invoicing data after an user was deleted
- Refactored user's profile to keep statistical data after an user was deleted
- Ability to delete an user (fixes #129 and #120)
-- Ask user acceptance before deposing analytics cookies
+- Ask user acceptance before deposing analytics cookies
- Fix a bug: (spanish) some translations are not loaded correctly
- Fix a bug: some users may not appear in the admin's general listing
- Fix a bug: Availabilities export report an erroneous number of reservations for machine availabilities (#131)
@@ -171,7 +246,7 @@
- Improved date checks before closing an accounting period
- Paginate list of coupons
- Allow filtering coupons list
-- Fix a bug: when VAT has changed during fab-manager's lifecycle, this may not be reflected in archives
+- Fix a bug: when VAT has changed during Fab-manager's lifecycle, this may not be reflected in archives
- Fix a bug: using a quote in event category's name results in angular $parse:syntax Error
## v3.0.1 2019 April 1st
@@ -204,7 +279,7 @@
- [TODO DEPLOY] `rake fablab:setup:chain_invoices_records`
- [TODO DEPLOY] `rake fablab:setup:chain_history_values_records`
- [TODO DEPLOY] add `DISK_SPACE_MB_ALERT` and `SUPERADMIN_EMAIL` environment variables (see [doc/environment.md](doc/environment.md) for configuration details)
-- [TODO DEPLOY] add the `accounting` volume to the fab-manager's image in [docker-compose.yml](docker/docker-compose.yml)
+- [TODO DEPLOY] add the `accounting` volume to the Fab-manager's image in [docker-compose.yml](docker/docker-compose.yml)
## v2.8.4 2019 March 18
@@ -332,8 +407,8 @@
- Fix a security issue: sprockets < 2.12.5 has a security vulnerability as described in [CVE-2018-3760](https://nvd.nist.gov/vuln/detail/CVE-2018-3760)
- Ensure elasticSearch indices are started with green status on new installations
- Refactored User.to_json to remove code duplication
-- Fixed syntax and typos in README
-- [TODO DEPLOY] **IMPORTANT** Please read [elastic_upgrade.md](doc/elastic_upgrade.md) for instructions on upgrading ElasticSearch.
+- Fixed syntax and typos in README
+- [TODO DEPLOY] **IMPORTANT** Please read [elastic_upgrade.md](doc/elastic_upgrade.md) for instructions on upgrading ElasticSearch.
- [TODO DEPLOY] `rake fablab:fix:categories_slugs`
- [TODO DEPLOY] -> (only dev) `bundle install`
- [TODO DEPLOY] `rake db:seed`
@@ -343,10 +418,10 @@
- Ability to share trainings on social medias
- Fix a bug: a reminder notification were sent for canceled reservations
- Fix a bug: sharing an event on facebook has HTML tags in the description
-- Set Stripe API version, all fab-managers has to use this version because codebase relies on it
+- Set Stripe API version, all Fab-managers has to use this version because codebase relies on it
- Fix a security issue: OmniAuth < 1.3.2 has a security vulnerability described in [CVE-2017-18076](https://nvd.nist.gov/vuln/detail/CVE-2017-18076)
- Fix a security issue: rack-protection < 1.5.5 has a security vulnerability described in [CVE-2018-1000119](https://nvd.nist.gov/vuln/detail/CVE-2018-1000119)
-- Fix a security issue: http gem < 0.7.3 has a security vulnerability described in [CVE-2015-1828](https://nvd.nist.gov/vuln/detail/CVE-2015-1828), updates twitter gem as a dependency
+- Fix a security issue: http gem < 0.7.3 has a security vulnerability described in [CVE-2015-1828](https://nvd.nist.gov/vuln/detail/CVE-2015-1828), updates twitter gem as a dependency
## v2.6.3 2018 January 2
@@ -402,12 +477,12 @@
## v2.5.13 2017 September 11
-- Fix a bug: ActiveRecord::RecordNotFound when running rake task fix:recursive_events_over_DST with recursive events which the initial event was deleted
+- Fix a bug: ActiveRecord::RecordNotFound when running rake task fix:recursive_events_over_DST with recursive events which the initial event was deleted
## v2.5.12 2017 September 11
- Fix a bug: Long words overflow from homepage's events blocks
-- Fix a bug: ActiveRecord::RecordNotFound when running rake task fix:recursive_events_over_DST with non-recursive events
+- Fix a bug: ActiveRecord::RecordNotFound when running rake task fix:recursive_events_over_DST with non-recursive events
## v2.5.11 2017 September 7
@@ -614,7 +689,7 @@
- Project images will show in full-size on a click
- Add a checkbox "I accept to receive informations from the FabLab" on Sign-up dialog and user's profile
- Share project with Facebook/Twitter
-- Display fab-manager's version in "Powered by" label, when logged as admin
+- Display Fab-manager's version in "Powered by" label, when logged as admin
- Load translation locales from subdirectories
- Add wallet to user, client can pay total/partial reservation or subscription by wallet
- Public calendar for show all trainings/machines/events
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d7ee792fc..862ce5648 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,6 +1,6 @@
-# Contributing to FabManager
+# Contributing to Fab-manager
-♥ [FabManager](http://www.fab-manager.com) and want to get involved?
+♥ [Fab-manager](http://www.fab-manager.com) and want to get involved?
Thanks! There are plenty of ways you can help!
Please take a moment to review this document in order to make the contribution process easy and effective for everyone
diff --git a/Gemfile b/Gemfile
index b69b1c8f9..2420d2e1a 100644
--- a/Gemfile
+++ b/Gemfile
@@ -4,7 +4,7 @@ gem 'compass-rails', '2.0.4'
# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
gem 'rails', '4.2.11.1'
# Use Puma as web server
-gem 'puma', '3.10.0'
+gem 'puma', '3.12.4'
# Use SCSS for stylesheets
gem 'sass-rails', '5.0.1'
@@ -61,7 +61,7 @@ group :test do
gem 'webmock'
end
-group :production do
+group :production, :staging do
gem 'rails_12factor'
end
@@ -89,9 +89,6 @@ gem 'mini_magick'
# upload files
gem 'carrierwave'
-gem 'twitter'
-gem 'twitter-text'
-
# slug url
gem 'friendly_id', '~> 5.1.0'
@@ -140,8 +137,8 @@ gem 'apipie-rails'
gem 'has_secure_token'
# XLS files generation
-gem 'axlsx_rails'
gem 'caxlsx'
+gem 'caxlsx_rails'
gem 'rubyzip', '>= 1.3.0'
gem 'rack-protection', '1.5.5'
@@ -152,3 +149,5 @@ gem 'sys-filesystem'
gem 'sha3'
gem 'repost'
+
+gem 'icalendar'
diff --git a/Gemfile.lock b/Gemfile.lock
index 0c1c06a6b..2e47bddec 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -56,16 +56,12 @@ GEM
descendants_tracker (~> 0.0.4)
ice_nine (~> 0.11.0)
thread_safe (~> 0.3, >= 0.3.1)
- axlsx_rails (0.6.0)
- actionpack (>= 3.1)
- caxlsx (>= 3.0)
bcrypt (3.1.13)
binding_of_caller (0.7.3)
debug_inspector (>= 0.0.1)
bootstrap-sass (3.4.1)
autoprefixer-rails (>= 5.2.1)
sassc (>= 2.0.0)
- buftok (0.2.0)
builder (3.2.3)
byebug (8.2.3)
camertron-eprun (1.1.0)
@@ -74,11 +70,14 @@ GEM
activesupport (>= 3.2.0)
json (>= 1.7)
mime-types (>= 1.16)
- caxlsx (3.0.0)
+ caxlsx (3.0.1)
htmlentities (~> 4.3, >= 4.3.4)
mimemagic (~> 0.3)
- nokogiri (~> 1.8, >= 1.8.2)
- rubyzip (~> 1.2, >= 1.2.1)
+ nokogiri (~> 1.10, >= 1.10.4)
+ rubyzip (>= 1.3.0, < 3)
+ caxlsx_rails (0.6.2)
+ actionpack (>= 3.1)
+ caxlsx (>= 3.0)
chroma (0.0.1)
chunky_png (1.3.4)
cldr-plurals-runtime-rb (1.0.1)
@@ -123,8 +122,6 @@ GEM
responders
warden (~> 1.2.3)
docile (1.1.5)
- domain_name (0.5.25)
- unf (>= 0.0.5, < 1.0.0)
elasticsearch (5.0.5)
elasticsearch-api (= 5.0.5)
elasticsearch-transport (= 5.0.5)
@@ -153,7 +150,7 @@ GEM
execjs (2.7.0)
faker (1.4.3)
i18n (~> 0.5)
- faraday (0.17)
+ faraday (0.17.0)
multipart-post (>= 1.2, < 3)
ffi (1.9.24)
figaro (1.1.0)
@@ -177,20 +174,14 @@ GEM
hashie (3.6.0)
hike (1.2.3)
htmlentities (4.3.4)
- http (3.0.0)
- addressable (~> 2.3)
- http-cookie (~> 1.0)
- http-form_data (>= 2.0.0.pre.pre2, < 3)
- http_parser.rb (~> 0.6.0)
- http-cookie (1.0.2)
- domain_name (~> 0.5)
- http-form_data (2.1.0)
- http_parser.rb (0.6.0)
httparty (0.13.7)
json (~> 1.8)
multi_xml (>= 0.5.2)
i18n (0.9.5)
concurrent-ruby (~> 1.0)
+ icalendar (2.5.3)
+ ice_cube (~> 0.16)
+ ice_cube (0.16.3)
ice_nine (0.11.2)
jaro_winkler (1.5.1)
jbuilder (2.5.0)
@@ -208,7 +199,7 @@ GEM
actionpack (>= 3.0.0)
activesupport (>= 3.0.0)
libv8 (3.16.14.19)
- loofah (2.3.0)
+ loofah (2.3.1)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
@@ -221,12 +212,10 @@ GEM
skinny (~> 0.2.3)
sqlite3 (~> 1.3)
thin (~> 1.5.0)
- memoizable (0.4.2)
- thread_safe (~> 0.3, >= 0.3.1)
message_format (0.0.3)
twitter_cldr (~> 3.1)
mime-types (2.99.3)
- mimemagic (0.3.3)
+ mimemagic (0.3.4)
mini_magick (4.9.4)
mini_mime (1.0.2)
mini_portile2 (2.4.0)
@@ -239,8 +228,7 @@ GEM
multi_json (1.14.1)
multi_xml (0.6.0)
multipart-post (2.1.1)
- naught (1.1.0)
- nokogiri (1.10.4)
+ nokogiri (1.10.8)
mini_portile2 (~> 2.4.0)
notify_with (0.0.2)
jbuilder (~> 2.0)
@@ -284,11 +272,11 @@ GEM
protected_attributes (1.1.3)
activemodel (>= 4.0.1, < 5.0)
public_suffix (3.0.2)
- puma (3.10.0)
+ puma (3.12.4)
pundit (1.0.0)
activesupport (>= 3.0.0)
raabro (1.1.6)
- rack (1.6.11)
+ rack (1.6.13)
rack-protection (1.5.5)
rack
rack-test (0.6.3)
@@ -335,7 +323,7 @@ GEM
recurrence (1.3.0)
activesupport
i18n
- redis (4.1.2)
+ redis (4.1.3)
redis-namespace (1.6.0)
redis (>= 3.0.4)
ref (2.0.0)
@@ -372,15 +360,14 @@ GEM
activerecord (~> 4)
activesupport (~> 4)
sha3 (1.0.1)
- sidekiq (5.2.7)
+ sidekiq (5.2.8)
connection_pool (~> 2.2, >= 2.2.2)
- rack (>= 1.5.0)
+ rack (< 2.1.0)
rack-protection (>= 1.5.0)
redis (>= 3.3.5, < 5)
sidekiq-cron (1.1.0)
fugit (~> 1.1)
sidekiq (>= 4.2.1)
- simple_oauth (0.3.1)
simplecov (0.12.0)
docile (~> 1.1.0)
json (>= 1.8, < 3)
@@ -423,19 +410,6 @@ GEM
tilt (1.4.1)
tins (1.13.0)
ttfunk (1.4.0)
- twitter (6.2.0)
- addressable (~> 2.3)
- buftok (~> 0.2.0)
- equalizer (~> 0.0.11)
- http (~> 3.0)
- http-form_data (~> 2.0)
- http_parser.rb (~> 0.6.0)
- memoizable (~> 0.4.0)
- multipart-post (~> 2.0)
- naught (~> 1.0)
- simple_oauth (~> 0.3.0)
- twitter-text (1.11.0)
- unf (~> 0.1.0)
twitter_cldr (3.2.1)
camertron-eprun
cldr-plurals-runtime-rb (~> 1.0.0)
@@ -445,9 +419,6 @@ GEM
thread_safe (~> 0.1)
uglifier (4.1.20)
execjs (>= 0.3.0, < 3)
- unf (0.1.4)
- unf_ext
- unf_ext (0.0.6)
unicode-display_width (1.4.0)
vcr (3.0.1)
virtus (1.0.5)
@@ -477,11 +448,11 @@ DEPENDENCIES
api-pagination
apipie-rails
awesome_print
- axlsx_rails
bootstrap-sass (>= 3.4.1)
byebug
carrierwave
caxlsx
+ caxlsx_rails
chroma
compass-rails (= 2.0.4)
coveralls
@@ -497,6 +468,7 @@ DEPENDENCIES
forgery
friendly_id (~> 5.1.0)
has_secure_token
+ icalendar
jbuilder (~> 2.5)
jbuilder_cache_multi
jquery-rails
@@ -516,7 +488,7 @@ DEPENDENCIES
prawn
prawn-table
protected_attributes
- puma (= 3.10.0)
+ puma (= 3.12.4)
pundit
rack-protection (= 1.5.5)
railroady
@@ -543,8 +515,6 @@ DEPENDENCIES
sys-filesystem
test_after_commit
therubyracer (= 0.12.0)
- twitter
- twitter-text
uglifier (>= 4.1.20)
vcr
web-console (~> 2.1.3)
diff --git a/README.md b/README.md
index d3fbc31cc..9e64b8952 100644
--- a/README.md
+++ b/README.md
@@ -1,42 +1,30 @@
-# FabManager
+# Fab-manager
-FabManager is the Fab Lab management solution. It provides a comprehensive, web-based, open-source tool to simplify your administrative tasks and your marker's projects.
+Fab-manager is the Fab Lab management solution. It provides a comprehensive, web-based, open-source tool to simplify your administrative tasks and your marker's projects.
[![Coverage Status](https://coveralls.io/repos/github/sleede/fab-manager/badge.svg)](https://coveralls.io/github/sleede/fab-manager)
[![Docker pulls](https://img.shields.io/docker/pulls/sleede/fab-manager.svg)](https://hub.docker.com/r/sleede/fab-manager/)
[![Docker Build Status](https://img.shields.io/docker/build/sleede/fab-manager.svg)](https://hub.docker.com/r/sleede/fab-manager/builds)
+[![Crowdin](https://badges.crowdin.net/fab-manager/localized.svg)](https://crowdin.com/project/fab-manager)
##### Table of Contents
1. [Software stack](#software-stack)
2. [Contributing](#contributing)
3. [Setup a production environment](#setup-a-production-environment)
-4. [Setup a development environment](#setup-a-development-environment)
-4.1. [General Guidelines](#general-guidelines)
-5. [PostgreSQL](#postgresql)
-5.1. [Install PostgreSQL 9.6](#setup-postgresql)
-6. [ElasticSearch](#elasticsearch)
-6.1. [Install ElasticSearch](#setup-elasticsearch)
-6.2. [Rebuild statistics](#rebuild-stats)
-6.3. [Backup and Restore](#backup-and-restore-elasticsearch)
-7. [Internationalization (i18n)](#i18n)
-7.1. [Translation](#i18n-translation)
-7.1.1. [Front-end translations](#i18n-translation-front)
-7.1.2. [Back-end translations](#i18n-translation-back)
-7.2. [Configuration](#i18n-configuration)
-7.2.1. [Settings](#i18n-settings)
-7.2.2. [Applying changes](#i18n-apply)
-8. [Open Projects](#open-projects)
-9. [Plugins](#plugins)
-10. [Single Sign-On](#sso)
-11. [Known issues](#known-issues)
-12. [Related Documentation](#related-documentation)
+4. [Setup a development environment](#setup-a-development-environment)
+5. [Internationalization (i18n)](#i18n)
+6. [Open Projects](#open-projects)
+7. [Plugins](#plugins)
+8. [Single Sign-On](#sso)
+9. [Known issues](#known-issues)
+10. [Related Documentation](#related-documentation)
## Software stack
-FabManager is a Ruby on Rails / AngularJS web application that runs on the following software:
+Fab-manager is a Ruby on Rails / AngularJS web application that runs on the following software:
- Ubuntu LTS 14.04+ / Debian 8+
- Ruby 2.3
@@ -53,311 +41,40 @@ Contributions are welcome. Please read [the contribution guidelines](CONTRIBUTIN
## Setup a production environment
-To run fab-manager as a production application, this is highly recommended to use [Docker-compose](https://docs.docker.com/compose/overview/).
-The procedure to follow is described in the [docker-compose readme](docker/README.md).
+To run Fab-manager as a production application, this is highly recommended to use [Docker-compose](https://docs.docker.com/compose/overview/).
+The procedure to follow is described in the [docker-compose readme](doc/docker-compose_readme.md).
## Setup a development environment
-In you intend to run fab-manager on your local machine to contribute to the project development, you can set it up with the following procedure.
+In you intend to run Fab-manager on your local machine to contribute to the project development, you can set it up by following the [development readme](doc/development_readme.md).
+This procedure relies on docker to set-up the dependencies.
-This procedure is not easy to follow so if you don't need to write some code for Fab-manager, please prefer the [docker-compose installation method](docker/README.md).
-
-Optionally, you can use a virtual development environment that relies on Vagrant and Virtual Box by following the [virtual machine instructions](doc/virtual-machine.md).
-
-
-### General Guidelines
-
-1. Install RVM, with the ruby version specified in the [.ruby-version file](.ruby-version).
- For more details about the process, please read the [official RVM documentation](http://rvm.io/rvm/install).
- If you're using ArchLinux, you may have to [read this](doc/archlinux_readme.md) before.
-
-2. Install NVM, with the node.js version specified in the [.nvmrc file](.nvmrc).
- For instructions about installing NVM, please refer to [the NVM readme](https://github.com/creationix/nvm#installation).
-
-3. Install Yarn, the front-end package manager.
- Depending on your system, the installation process may differ, please read the [official Yarn documentation](https://yarnpkg.com/en/docs/install#debian-stable).
-
-4. Install docker.
- Your system may provide a pre-packaged version of docker in its repositories, but this version may be outdated.
- Please refer to [ubuntu](https://docs.docker.com/install/linux/docker-ce/ubuntu/), [debian](https://docs.docker.com/install/linux/docker-ce/debian/) or [MacOS](https://docs.docker.com/docker-for-mac/install/) documentation to setup a recent version of docker.
-
-5. Add your current user to the docker group, to allow using docker without `sudo`.
- ```bash
- # add the docker group if it doesn't already exist
- sudo groupadd docker
- # add the current user to the docker group
- sudo usermod -aG docker $(whoami)
- # restart to validate changes
- sudo reboot
- ```
-
-6. Create a docker network for fab-manager.
- You may have to change the network address if it is already in use.
- ```bash
- docker network create --subnet=172.18.0.0/16 fabmanager
- ```
-
-7. Retrieve the project from Git
-
- ```bash
- git clone https://github.com/sleede/fab-manager.git
- ```
-
-8. Install the software dependencies.
- First install [PostgreSQL](#postgresql) and [ElasticSearch](#elasticsearch) as specified in their respective documentations.
- Then install the other dependencies:
- - For Ubuntu/Debian:
-
- ```bash
- # on Ubuntu 18.04 server, you may have to enable the "universe" repository
- sudo add-apt-repository universe
- # then, install the dependencies
- sudo apt-get install libpq-dev redis-server imagemagick
- ```
- - For MacOS X:
-
- ```bash
- brew install redis imagemagick
- ```
-
-9. Init the RVM and NVM instances and check they were correctly configured
-
- ```bash
- cd fab-manager
- rvm current | grep -q `cat .ruby-version`@fab-manager && echo "ok"
- # Must print ok
- nvm use
- node --version | grep -q `cat .nvmrc` && echo "ok"
- # Must print ok
- ```
-
-10. Install bundler in the current RVM gemset
-
- ```bash
- gem install bundler --version=1.17.3
- ```
-
-11. Install the required ruby gems and javascript plugins
-
- ```bash
- bundle install
- yarn install
- ```
-
-12. Create the default configuration files **and configure them!** (see the [environment configuration documentation](doc/environment.md))
-
- ```bash
- cp config/database.yml.default config/database.yml
- cp config/application.yml.default config/application.yml
- vi config/application.yml
- # or use your favorite text editor instead of vi (nano, ne...)
- ```
-
-13. Build the databases.
- - **Warning**: **DO NOT** run `rake db:setup` instead of these commands, as this will not run some required raw SQL instructions.
- - **Please note**: Your password length must be between 8 and 128 characters, otherwise db:seed will be rejected. This is configured in [config/initializers/devise.rb](config/initializers/devise.rb)
-
- ```bash
- # for dev
- rake db:create
- rake db:migrate
- ADMIN_EMAIL='youradminemail' ADMIN_PASSWORD='youradminpassword' rake db:seed
- rake fablab:es:build_stats
- # for tests
- RAILS_ENV=test rake db:create
- RAILS_ENV=test rake db:migrate
- ```
-
-14. Create the pids folder used by Sidekiq. If you want to use a different location, you can configure it in `config/sidekiq.yml`
-
- ```bash
- mkdir -p tmp/pids
- ```
-
-15. Start the development web server
-
- ```bash
- foreman s -p 3000
- ```
-
-16. You should now be able to access your local development FabManager instance by accessing `http://localhost:3000` in your web browser.
-
-17. You can login as the default administrator using the credentials defined previously.
-
-18. Email notifications will be caught by MailCatcher.
- To see the emails sent by the platform, open your web browser at `http://localhost:1080` to access the MailCatcher interface.
-
-
-
-## PostgreSQL
-
-
-### Install PostgreSQL 9.6
-
-We will use docker to easily install the required version of PostgreSQL.
-
-1. Create the docker binding folder
- ```bash
- mkdir -p .docker/postgresql
- ```
-
-2. Start the PostgreSQL container.
- ```bash
- docker run --restart=always -d --name fabmanager-postgres \
- -v $(pwd)/.docker/postgresql:/var/lib/postgresql/data \
- --network fabmanager --ip 172.18.0.2 \
- -p 5432:5432 \
- postgres:9.6
- ```
-
-3. Configure fab-manager to use it.
- On linux systems, PostgreSQL will be available at 172.18.0.2.
- On MacOS, you'll have to set the host to 127.0.0.1 (or localhost).
- See [environment.md](doc/environment.md) for more details.
-
-4 . Finally, you may want to have a look at detailed informations about PostgreSQL usage in fab-manager.
- Some information about that is available in the [PostgreSQL Readme](doc/postgresql_readme.md).
-
-
-## ElasticSearch
-
-ElasticSearch is a powerful search engine based on Apache Lucene combined with a NoSQL database used as a cache to index data and quickly process complex requests on it.
-
-In FabManager, it is used for the admin's statistics module and to perform searches in projects.
-
-
-### Install ElasticSearch
-
-1. Create the docker binding folders
- ```bash
- mkdir -p .docker/elasticsearch/config
- mkdir -p .docker/elasticsearch/plugins
- mkdir -p .docker/elasticsearch/backups
- ```
-
-2. Copy the default configuration files
- ```bash
- cp docker/elasticsearch.yml .docker/elasticsearch/config
- cp docker/log4j2.properties .docker/elasticsearch/config
- ```
-
-3. Start the ElasticSearch container.
- ```bash
- docker run --restart=always -d --name fabmanager-elastic \
- -v $(pwd)/.docker/elasticsearch/config:/usr/share/elasticsearch/config \
- -v $(pwd)/.docker/elasticsearch:/usr/share/elasticsearch/data \
- -v $(pwd)/.docker/elasticsearch/plugins:/usr/share/elasticsearch/plugins \
- -v $(pwd)/.docker/elasticsearch/backups:/usr/share/elasticsearch/backups \
- --network fabmanager --ip 172.18.0.3 \
- -p 9200:9200 -p 9300:9300 \
- elasticsearch:5.6
- ```
-
-4. Configure fab-manager to use it.
- On linux systems, ElasticSearch will be available at 172.18.0.3.
- On MacOS, you'll have to set the host to 127.0.0.1 (or localhost).
- See [environment.md](doc/environment.md) for more details.
-
-
-### Rebuild statistics
-
-Every nights, the statistics for the day that just ended are built automatically at 01:00 (AM) and stored in ElastricSearch.
-See [schedule.yml](config/schedule.yml) to modify this behavior.
-If the scheduled task wasn't executed for any reason (eg. you are in a dev environment and your computer was turned off at 1 AM), you can force the statistics data generation in ElasticSearch, running the following command.
-
-```bash
-# Here for the 50 last days
-rake fablab:es:generate_stats[50]
-```
-
-
-### Backup and Restore
-
-To backup and restore the ElasticSearch database, use the [elasticsearch-dump](https://github.com/taskrabbit/elasticsearch-dump) tool.
-
-Dump the database with: `elasticdump --input=http://localhost:9200/stats --output=fablab_stats.json`.
-Restore it with: `elasticdump --input=fablab_stats.json --output=http://localhost:9200/stats`.
+Optionally, you can use a virtual development environment that relies on Vagrant and Virtual Box by following the [virtual machine instructions](virtual-machine.md).
## Internationalization (i18n)
-The FabManager application can only run in a single language but this language can easily be changed.
+The Fab-manager application can only run in a single language but this language can easily be changed.
-
-### Translation
-
-Check the files located in `config/locales`:
-
-- Front app translations (angular.js) are located in `config/locales/app.scope.XX.yml`.
- Where scope has one the following meaning :
- - admin: translations of the administrator views (manage and configure the FabLab).
- - logged: translations of the end-user's views accessible only to connected users.
- - public: translation of end-user's views publicly accessible to anyone.
- - shared: translations shared by many views (like forms or buttons).
-- Back app translations (Ruby on Rails) are located in `config/locales/XX.yml`.
-- Emails translations are located in `config/locales/mails.XX.yml`.
-- Messages related to the authentication system are located in `config/locales/devise.XX.yml`.
-
-If you plan to translate the application to a new locale, please consider that the reference translation is French.
-Indeed, in some cases, the English texts/sentences can seems confuse or lack of context as they were originally translated from French.
-
-To prevent syntax mistakes while translating locale files, we **STRONGLY advise** you to use a text editor which support syntax coloration for YML and Ruby.
-
-
-#### Front-end translations
-
-Front-end translations uses [angular-translate](http://angular-translate.github.io) with some interpolations interpreted by angular.js and other interpreted by [MessageFormat](https://github.com/SlexAxton/messageformat.js/).
-**These two kinds of interpolation use a near but different syntax witch SHOULD NOT be confused.**
-Please refer to the official [angular-translate documentation](http://angular-translate.github.io/docs/#/guide/14_pluralization) before translating.
-
-
-#### Back-end translations
-
-Back-end translations uses the [Ruby on Rails syntax](http://guides.rubyonrails.org/i18n.html) but some complex interpolations are interpreted by [MessageFormat](https://github.com/format-message/message-format-rb) and are marked as it in comments.
-**DO NOT confuse the syntaxes.**
-
-In each cases, some inline comments are included in the localisation files.
-They can be recognized as they start with the sharp character (#).
-These comments are not required to be translated, they are intended to help the translator to have some context information about the sentence to translate.
-
-You will also need to translate the invoice watermark, located in `app/pdfs/data/`.
-You'll find there the [GIMP source of the image](app/pdfs/data/watermark.xcf), which is using [Rubik Mono One](https://fonts.google.com/specimen/Rubik+Mono+One) as font.
-Use it to generate a similar localised PNG image which keep the default image size, as PDF are not responsive.
-
-
-
-### Configuration
-
-Locales configurations are made in `config/application.yml`.
-If you are in a development environment, your can keep the default values, otherwise, in production, values must be configured carefully.
-
-
-#### Settings
-
-Please refer to the [environment configuration documentation](doc/environment.md#internationalization-settings)
-
-
-#### Applying changes
-
-After modifying any values concerning the localisation, restart the application (ie. web server) to apply these changes in the i18n configuration.
+Please refer to the [translation readme](doc/translation_readme.md) for instructions about configuring the language or to contribute to the translation.
## Open Projects
**This configuration is optional.**
-You can configure your fab-manager to synchronize every project with the [Open Projects platform](https://github.com/sleede/openlab-projects).
-It's very simple and straightforward and in return, your users will be able to search over projects from all fab-manager instances from within your platform.
+You can configure your Fab-manager to synchronize every project with the [Open Projects platform](https://github.com/sleede/openlab-projects).
+It's very simple and straightforward and in return, your users will be able to search over projects from all Fab-manager instances from within your platform.
The deal is fair, you share your projects and as reward you benefits from projects of the whole community.
-If you want to try it, you can visit [this fab-manager](https://fablab.lacasemate.fr/#!/projects) and see projects from different fab-managers.
+If you want to try it, you can visit [this Fab-manager](https://fablab.lacasemate.fr/#!/projects) and see projects from different Fab-managers.
To start using this awesome feature, there are a few steps:
-- send a mail to **contact@fab-manager.com** asking for your Open Projects client's credentials and giving them the name of your fab-manager, they will give you an `OPENLAB_APP_ID` and an `OPENLAB_APP_SECRET`
+- send a mail to **contact@fab-manager.com** asking for your Open Projects client's credentials and giving them the name of your Fab-manager, they will give you an `OPENLAB_APP_ID` and an `OPENLAB_APP_SECRET`
- fill in the value of the keys in your environment file
-- start your fab-manager app
-- export your projects to open-projects (if you already have projects created on your fab-manager, unless you can skip that part) executing this command: `bundle exec rake fablab:openlab:bulk_export`
+- start your Fab-manager app
+- export your projects to open-projects (if you already have projects created on your Fab-manager, unless you can skip that part) executing this command: `bundle exec rake fablab:openlab:bulk_export`
**IMPORTANT: please run your server in production mode.**
@@ -390,51 +107,7 @@ Developers may find information on how to implement their own authentication pro
## Known issues
-- When browsing a machine page, you may encounter an "InterceptError" in the console and the loading bar will stop loading before reaching its ending.
- This may happen if the machine was created through a seed file without any image.
- To solve this, simply add an image to the machine's profile and refresh the web page.
-
-- When starting the Ruby on Rails server (eg. `foreman s`) you may receive the following error:
-
- worker.1 | invalid url: redis::6379
- web.1 | Exiting
- worker.1 | ...lib/redis/client.rb...:in `_parse_options'
-
- This may happen when the `application.yml` file is missing.
- To solve this issue copy `config/application.yml.default` to `config/application.yml`.
- This is required before the first start.
-
-- Due to a stripe limitation, you won't be able to create plans longer than one year.
-
-- When running the tests suite with `rake test`, all tests may fail with errors similar to the following:
-
- Error:
- ...
- ActiveRecord::InvalidForeignKey: PG::ForeignKeyViolation: ERROR: insert or update on table "..." violates foreign key constraint "fk_rails_..."
- DETAIL: Key (group_id)=(1) is not present in table "...".
- : ...
- test_after_commit (1.0.0) lib/test_after_commit/database_statements.rb:11:in `block in transaction'
- test_after_commit (1.0.0) lib/test_after_commit/database_statements.rb:5:in `transaction'
-
- This is due to an ActiveRecord behavior witch disable referential integrity in PostgreSQL to load the fixtures.
- PostgreSQL will prevent any users to disable referential integrity on the fly if they doesn't have the `SUPERUSER` role.
- To fix that, logon as the `postgres` user and run the PostgreSQL shell (see [the dedicated section](#run-postgresql-cli) for instructions).
- Then, run the following command (replace `sleede` with your test database user, as specified in your database.yml):
-
- ALTER ROLE sleede WITH SUPERUSER;
-
- DO NOT do this in a production environment, unless you know what you're doing: this could lead to a serious security issue.
-
-- With Ubuntu 16.04, ElasticSearch may refuse to start even after having configured the service with systemd.
- To solve this issue, you may have to set `START_DAEMON` to `true` in `/etc/default/elasticsearch`.
- Then reload ElasticSearch with:
-
- ```bash
- sudo systemctl restart elasticsearch.service
- ```
-- In some cases, the invoices won't be generated. This can be due to the image included in the invoice header not being supported.
- To fix this issue, change the image in the administrator interface (manage the invoices / invoices settings).
- See [this thread](https://forum.fab-manager.com/t/resolu-erreur-generation-facture/428) for more info.
+Before reporting an issue, please check if your issue is not listed in the [know issues](doc/known-issues.md) with its solution.
## Related Documentation
diff --git a/app/assets/javascripts/app.js b/app/assets/javascripts/app.js
index 01b4265e9..f6f36e803 100644
--- a/app/assets/javascripts/app.js
+++ b/app/assets/javascripts/app.js
@@ -20,9 +20,10 @@ angular.module('application', ['ngCookies', 'ngResource', 'ngSanitize', 'ui.rout
'ui.select', 'ui.calendar', 'angularMoment', 'Devise', 'DeviseModal', 'angular-growl', 'xeditable',
'checklist-model', 'unsavedChanges', 'angular-loading-bar', 'ngTouch', 'angular-google-analytics',
'angularUtils.directives.dirDisqus', 'summernote', 'elasticsearch', 'angular-medium-editor', 'naif.base64',
- 'minicolors', 'pascalprecht.translate', 'ngFitText', 'ngAside', 'ngCapsLock', 'vcRecaptcha'])
- .config(['$httpProvider', 'AuthProvider', 'growlProvider', 'unsavedWarningsConfigProvider', 'AnalyticsProvider', 'uibDatepickerPopupConfig', '$provide', '$translateProvider',
- function ($httpProvider, AuthProvider, growlProvider, unsavedWarningsConfigProvider, AnalyticsProvider, uibDatepickerPopupConfig, $provide, $translateProvider) {
+ 'minicolors', 'pascalprecht.translate', 'ngFitText', 'ngAside', 'ngCapsLock', 'vcRecaptcha', 'ui.codemirror',
+ 'bm.uiTour'])
+ .config(['$httpProvider', 'AuthProvider', 'growlProvider', 'unsavedWarningsConfigProvider', 'AnalyticsProvider', 'uibDatepickerPopupConfig', '$provide', '$translateProvider', 'TourConfigProvider',
+ function ($httpProvider, AuthProvider, growlProvider, unsavedWarningsConfigProvider, AnalyticsProvider, uibDatepickerPopupConfig, $provide, $translateProvider, TourConfigProvider) {
// Google analytics
// first we check the user acceptance
const cookiesConsent = document.cookie.replace(/(?:(?:^|.*;\s*)fab-manager-cookies-consent\s*=\s*([^;]*).*$)|^.*$/, '$1');
@@ -59,10 +60,12 @@ angular.module('application', ['ngCookies', 'ngResource', 'ngSanitize', 'ui.rout
$translateProvider.useLoaderCache(true);
// Secure i18n module against XSS attacks by escaping the output
$translateProvider.useSanitizeValueStrategy('escapeParameters');
- // Enable the MessageFormat interpolation (used for pluralization)
- $translateProvider.addInterpolation('$translateMessageFormatInterpolation');
+ // Use the MessageFormat interpolation by default (used for pluralization)
+ $translateProvider.useMessageFormatInterpolation();
// Set the langage of the instance (from ruby configuration)
$translateProvider.preferredLanguage(Fablab.locale);
+ // End the tour when the user clicks the forward or back buttons of the browser
+ TourConfigProvider.enableNavigationInterceptors();
}]).run(['$rootScope', '$log', 'AuthService', 'Auth', 'amMoment', '$state', 'editableOptions', 'Analytics',
function ($rootScope, $log, AuthService, Auth, amMoment, $state, editableOptions, Analytics) {
// Angular-moment (date-time manipulations library)
@@ -86,6 +89,16 @@ angular.module('application', ['ngCookies', 'ngResource', 'ngSanitize', 'ui.rout
$rootScope.fablabWithoutOnlinePayment = Fablab.withoutOnlinePayment;
// Global config: if true, no invoices will be generated
$rootScope.fablabWithoutInvoices = Fablab.withoutInvoices;
+ // Global config: if true, the phone number is required to create an account
+ $rootScope.phoneRequired = Fablab.phoneRequired;
+ // Global config: if true, the events are shown in the admin calendar
+ $rootScope.eventsInCalendar = Fablab.eventsInCalendar;
+ // Global config: machine/space slot duration
+ $rootScope.slotDuration = Fablab.slotDuration;
+ // Global config: if true, user must confirm his email to sign in
+ $rootScope.userConfirmationNeededToSignIn = Fablab.userConfirmationNeededToSignIn;
+ // Global config: if true, wallet will be disable
+ $rootScope.fablabWithoutWallet = Fablab.fablabWithoutWallet;
// Global function to allow the user to navigate to the previous screen (ie. $state).
// If no previous $state were recorded, navigate to the home page
diff --git a/app/assets/javascripts/application.js.erb b/app/assets/javascripts/application.js.erb
index eaf6f38a8..9a5b256e8 100644
--- a/app/assets/javascripts/application.js.erb
+++ b/app/assets/javascripts/application.js.erb
@@ -21,7 +21,7 @@
//= require angular-cookies
//= require angular-touch
//= require @uirouter/angularjs/release/angular-ui-router
-//= require angular-ui-bootstrap/ui-bootstrap-tpls
+//= require angular-ui-bootstrap/dist/ui-bootstrap-tpls
//= require ui-select/dist/select
//= require moment/moment
//= require moment-timezone/builds/moment-timezone-with-data-2012-2022
@@ -46,6 +46,7 @@
//= require elasticsearch-browser/elasticsearch.angular
//= require d3/d3
//= require nvd3/build/nv.d3.js
+//= require twitter-fetcher
//= require app
//= require router
//= require medium-editor/dist/js/medium-editor
@@ -55,6 +56,7 @@
//= require angular-base64-upload/dist/angular-base64-upload.min
//= require summernote/dist/summernote
//= require angular-summernote/dist/angular-summernote
+//= require summernote-ext-nugget
//= require jquery-minicolors/jquery.minicolors.js
//= require angular-minicolors/angular-minicolors.js
//= require angular-translate/dist/angular-translate
@@ -65,6 +67,16 @@
//= require angular-aside/dist/js/angular-aside
//= require ng-caps-lock/ng-caps-lock
//= require angular-recaptcha
+//= require codemirror/lib/codemirror
+//= require codemirror/addon/edit/matchbrackets
+//= require codemirror/mode/css/css
+//= require codemirror/mode/sass/sass
+//= require angular-ui-codemirror/src/ui-codemirror
+//= require angular-hotkeys/build/hotkeys
+//= require hone/dist/hone
+//= require tether/dist/js/tether
+//= require angular-bind-html-compile/angular-bind-html-compile
+//= require angular-ui-tour/dist/angular-ui-tour
//= require_tree ./controllers
//= require_tree ./services
//= require_tree ./directives
diff --git a/app/assets/javascripts/controllers/admin/abuses.js b/app/assets/javascripts/controllers/admin/abuses.js
index 2d1741a00..21da2309b 100644
--- a/app/assets/javascripts/controllers/admin/abuses.js
+++ b/app/assets/javascripts/controllers/admin/abuses.js
@@ -17,21 +17,21 @@ Application.Controllers.controller('AbusesController', ['$scope', '$state', 'Abu
resolve: {
object () {
return {
- title: _t('manage_abuses.confirmation_required'),
- msg: _t('manage_abuses.report_will_be_destroyed')
+ title: _t('app.admin.manage_abuses.confirmation_required'),
+ msg: _t('app.admin.manage_abuses.report_will_be_destroyed')
};
}
}
},
function () { // cancel confirmed
Abuse.remove({ id: abuseId }, function () { // successfully canceled
- growl.success(_t('manage_abuses.report_removed'));
+ growl.success(_t('app.admin.manage_abuses.report_removed'));
Abuse.query({}, function (abuses) {
$scope.abuses = abuses.abuses.filter(a => a.signaled_type === 'Project');
});
}
, function () { // error while canceling
- growl.error(_t('manage_abuses.failed_to_remove'));
+ growl.error(_t('app.admin.manage_abuses.failed_to_remove'));
});
}
);
diff --git a/app/assets/javascripts/controllers/admin/authentications.js.erb b/app/assets/javascripts/controllers/admin/authentications.js.erb
index 10852ea53..9736b265d 100644
--- a/app/assets/javascripts/controllers/admin/authentications.js.erb
+++ b/app/assets/javascripts/controllers/admin/authentications.js.erb
@@ -54,14 +54,16 @@ const check_oauth2_id_is_mapped = function (mappings) {
* - $scope.authMethods
* - $scope.mappingFields
* - $scope.cancel()
+ * - $scope.methodName()
* - $scope.defineDataMapping(mapping)
*
* Requires :
* - mappingFieldsPromise: retrieved by AuthProvider.mapping_fields()
* - $state (Ui-Router) [ 'app.admin.members' ]
+ * - _t : translation method
*/
class AuthenticationController {
- constructor ($scope, $state, $uibModal, mappingFieldsPromise) {
+ constructor ($scope, $state, $uibModal, _t, mappingFieldsPromise) {
// list of supported authentication methods
$scope.authMethods = METHODS;
@@ -73,6 +75,13 @@ class AuthenticationController {
*/
$scope.cancel = function () { $state.go('app.admin.members'); };
+ /**
+ * Return a localized string for the provided method
+ */
+ $scope.methodName = function(method) {
+ return _t('app.shared.authentication.' + METHODS[method]);
+ }
+
/**
* Open a modal allowing to specify the data mapping for the given field
*/
@@ -137,10 +146,10 @@ class AuthenticationController {
$scope.ok = function () { $uibModalInstance.close($scope.transformation.rules); };
// do not save the modifications
- return $scope.cancel = function () { $uibModalInstance.dismiss(); };
- }
- ] })
- .result['finally'](null).then(function (transfo_rules) { mapping.transformation = transfo_rules; });
+ $scope.cancel = function () { $uibModalInstance.dismiss(); };
+ }]
+ })
+ .result['finally'](null).then(function (transfo_rules) { mapping.transformation = transfo_rules; });
};
}
}
@@ -163,9 +172,9 @@ Application.Controllers.controller('AuthentificationController', ['$scope', '$st
$scope.getType = function (type) {
const text = METHODS[type];
if (typeof text !== 'undefined') {
- return _t(text);
+ return _t(`app.admin.members.authentication_form.${text}`);
} else {
- return _t('unknown') + type;
+ return _t('app.admin.members.authentication_form.unknown') + type;
}
};
@@ -176,10 +185,10 @@ Application.Controllers.controller('AuthentificationController', ['$scope', '$st
*/
$scope.getState = function (status) {
switch (status) {
- case 'active': return _t('active');
- case 'pending': return _t('pending');
- case 'previous': return _t('previous_provider');
- default: return _t('unknown') + status;
+ case 'active': return _t('app.admin.members.authentication_form.active');
+ case 'pending': return _t('app.admin.members.authentication_form.pending');
+ case 'previous': return _t('app.admin.members.authentication_form.previous_provider');
+ default: return _t('app.admin.members.authentication_form.unknown') + status;
}
};
@@ -194,8 +203,8 @@ Application.Controllers.controller('AuthentificationController', ['$scope', '$st
resolve: {
object () {
return {
- title: _t('confirmation_required'),
- msg: _t('do_you_really_want_to_delete_the_TYPE_authentication_provider_NAME', { TYPE: $scope.getType(provider.providable_type), NAME: provider.name })
+ title: _t('app.admin.members.authentication_form.confirmation_required'),
+ msg: _t('app.admin.members.authentication_form.do_you_really_want_to_delete_the_TYPE_authentication_provider_NAME', { TYPE: $scope.getType(provider.providable_type), NAME: provider.name })
};
}
}
@@ -206,9 +215,9 @@ Application.Controllers.controller('AuthentificationController', ['$scope', '$st
{ id: provider.id },
function () {
providers.splice(findIdxById(providers, provider.id), 1);
- growl.success(_t('authentication_provider_successfully_deleted'));
+ growl.success(_t('app.admin.members.authentication_form.authentication_provider_successfully_deleted'));
},
- function () { growl.error(_t('an_error_occurred_unable_to_delete_the_specified_provider')); }
+ function () { growl.error(_t('app.admin.members.authentication_form.an_error_occurred_unable_to_delete_the_specified_provider')); }
);
}
);
@@ -254,19 +263,19 @@ Application.Controllers.controller('NewAuthenticationController', ['$scope', '$s
// prevent from adding mode than 1
for (provider of Array.from(authProvidersPromise)) {
if (provider.providable_type === 'DatabaseProvider') {
- growl.error(_t('a_local_database_provider_already_exists_unable_to_create_another'));
+ growl.error(_t('app.admin.authentication_new.a_local_database_provider_already_exists_unable_to_create_another'));
return false;
}
}
return AuthProvider.save({ auth_provider: $scope.provider }, function (provider) {
- growl.success(_t('local_provider_successfully_saved'));
+ growl.success(_t('app.admin.authentication_new.local_provider_successfully_saved'));
return $state.go('app.admin.members');
});
// === OAuth2Provider ===
} else if ($scope.provider.providable_type === 'OAuth2Provider') {
// check the ID mapping
if (!check_oauth2_id_is_mapped($scope.provider.providable_attributes.o_auth2_mappings_attributes)) {
- growl.error(_t('it_is_required_to_set_the_matching_between_User.uid_and_the_API_to_add_this_provider'));
+ growl.error(_t('app.admin.authentication_new.it_is_required_to_set_the_matching_between_User.uid_and_the_API_to_add_this_provider'));
return false;
}
// discourage the use of unsecure SSO
@@ -277,24 +286,24 @@ Application.Controllers.controller('NewAuthenticationController', ['$scope', '$s
resolve: {
object () {
return {
- title: _t('security_issue_detected'),
- msg: _t('beware_the_oauth2_authenticatoin_provider_you_are_about_to_add_isnt_using_HTTPS') +
- _t('this_is_a_serious_security_issue_on_internet_and_should_never_be_used_except_for_testing_purposes') +
- _t('do_you_really_want_to_continue')
+ title: _t('app.admin.authentication_new.security_issue_detected'),
+ msg: _t('app.admin.authentication_new.beware_the_oauth2_authenticatoin_provider_you_are_about_to_add_isnt_using_HTTPS') +
+ _t('app.admin.authentication_new.this_is_a_serious_security_issue_on_internet_and_should_never_be_used_except_for_testing_purposes') +
+ _t('app.admin.authentication_new.do_you_really_want_to_continue')
};
}
}
},
function () { // unsecured http confirmed
AuthProvider.save({ auth_provider: $scope.provider }, function (provider) {
- growl.success(_t('unsecured_oauth2_provider_successfully_added'));
+ growl.success(_t('app.admin.authentication_new.unsecured_oauth2_provider_successfully_added'));
return $state.go('app.admin.members');
});
}
);
} else {
AuthProvider.save({ auth_provider: $scope.provider }, function (provider) {
- growl.success(_t('oauth2_provider_successfully_added'));
+ growl.success(_t('app.admin.authentication_new.oauth2_provider_successfully_added'));
return $state.go('app.admin.members');
});
}
@@ -302,7 +311,7 @@ Application.Controllers.controller('NewAuthenticationController', ['$scope', '$s
};
// Using the AuthenticationController
- return new AuthenticationController($scope, $state, $uibModal, mappingFieldsPromise);
+ return new AuthenticationController($scope, $state, $uibModal, _t, mappingFieldsPromise);
}
]);
@@ -322,21 +331,21 @@ Application.Controllers.controller('EditAuthenticationController', ['$scope', '$
$scope.updateProvider = function () {
// check the ID mapping
if (!check_oauth2_id_is_mapped($scope.provider.providable_attributes.o_auth2_mappings_attributes)) {
- growl.error(_t('it_is_required_to_set_the_matching_between_User.uid_and_the_API_to_add_this_provider'));
+ growl.error(_t('app.admin.authentication_edit.it_is_required_to_set_the_matching_between_User.uid_and_the_API_to_add_this_provider'));
return false;
}
return AuthProvider.update(
{ id: $scope.provider.id },
{ auth_provider: $scope.provider },
function (provider) {
- growl.success(_t('provider_successfully_updated'));
+ growl.success(_t('app.admin.authentication_edit.provider_successfully_updated'));
$state.go('app.admin.members');
},
- function () { growl.error(_t('an_error_occurred_unable_to_update_the_provider')); }
+ function () { growl.error(_t('app.admin.authentication_edit.an_error_occurred_unable_to_update_the_provider')); }
);
};
// Using the AuthenticationController
- return new AuthenticationController($scope, $state, $uibModal, mappingFieldsPromise);
+ return new AuthenticationController($scope, $state, $uibModal, _t, mappingFieldsPromise);
}
]);
diff --git a/app/assets/javascripts/controllers/admin/calendar.js.erb b/app/assets/javascripts/controllers/admin/calendar.js.erb
index 6e35d5a91..0a6fe92b5 100644
--- a/app/assets/javascripts/controllers/admin/calendar.js.erb
+++ b/app/assets/javascripts/controllers/admin/calendar.js.erb
@@ -18,19 +18,18 @@
* Controller used in the calendar management page
*/
-Application.Controllers.controller('AdminCalendarController', ['$scope', '$state', '$uibModal', 'moment', 'Availability', 'Slot', 'Setting', 'Export', 'growl', 'dialogs', 'bookingWindowStart', 'bookingWindowEnd', 'machinesPromise', '_t', 'uiCalendarConfig', 'CalendarConfig',
- function ($scope, $state, $uibModal, moment, Availability, Slot, Setting, Export, growl, dialogs, bookingWindowStart, bookingWindowEnd, machinesPromise, _t, uiCalendarConfig, CalendarConfig) {
- /* PRIVATE STATIC CONSTANTS */
+Application.Controllers.controller('AdminCalendarController', ['$scope', '$state', '$uibModal', 'moment', 'Availability', 'Slot', 'Setting', 'Export', 'growl', 'dialogs', 'bookingWindowStart', 'bookingWindowEnd', 'machinesPromise', 'plansPromise', 'groupsPromise', '_t', 'uiCalendarConfig', 'CalendarConfig', 'Member', 'uiTourService',
+ function ($scope, $state, $uibModal, moment, Availability, Slot, Setting, Export, growl, dialogs, bookingWindowStart, bookingWindowEnd, machinesPromise, plansPromise, groupsPromise, _t, uiCalendarConfig, CalendarConfig, Member, uiTourService) {
+ /* PRIVATE STATIC CONSTANTS */
// The calendar is divided in slots of 30 minutes
- let loadingCb;
const BASE_SLOT = '00:30:00';
// The bookings can be positioned every half hours
const BOOKING_SNAP = '00:30:00';
// We do not allow the creation of slots that are not a multiple of 60 minutes
- const SLOT_MULTIPLE = 60;
+ const SLOT_MULTIPLE = Fablab.slotDuration;
/* PUBLIC SCOPE */
@@ -40,6 +39,9 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
// currently selected availability
$scope.availability = null;
+ // corresponding fullCalendar item in the DOM
+ $scope.availabilityDom = null;
+
// bind the availabilities slots with full-Calendar events
$scope.eventSources = [];
$scope.eventSources.push({
@@ -62,7 +64,10 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
return calendarEventClickCb(event, jsEvent, view);
},
eventRender (event, element, view) {
- return eventRenderCb(event, element);
+ return eventRenderCb(event, element, view);
+ },
+ viewRender(view, element) {
+ return viewRenderCb(view, element);
},
loading (isLoading, view) {
return loadingCb(isLoading, view);
@@ -80,10 +85,9 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
resolve: {
object () {
return {
- title: _t('admin_calendar.confirmation_required'),
- msg: _t('admin_calendar.do_you_really_want_to_cancel_the_USER_s_reservation_the_DATE_at_TIME_concerning_RESERVATION'
- , { GENDER: getGender($scope.currentUser), USER: slot.user.name, DATE: moment(slot.start_at).format('L'), TIME: moment(slot.start_at).format('LT'), RESERVATION: slot.reservable.name }
- , 'messageformat')
+ title: _t('app.admin.calendar.confirmation_required'),
+ msg: _t('app.admin.calendar.do_you_really_want_to_cancel_the_USER_s_reservation_the_DATE_at_TIME_concerning_RESERVATION'
+ , { GENDER: getGender($scope.currentUser), USER: slot.user.name, DATE: moment(slot.start_at).format('L'), TIME: moment(slot.start_at).format('LT'), RESERVATION: slot.reservable.name })
};
}
}
@@ -101,10 +105,10 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
}
}
// notify the admin
- return growl.success(_t('admin_calendar.reservation_was_successfully_cancelled'));
+ return growl.success(_t('app.admin.calendar.reservation_was_successfully_cancelled'));
},
function (data, status) { // failed
- growl.error(_t('admin_calendar.reservation_cancellation_failed'));
+ growl.error(_t('app.admin.calendar.reservation_cancellation_failed'));
}
);
}
@@ -118,17 +122,17 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
*/
$scope.removeMachine = function (machine) {
if ($scope.availability.machine_ids.length === 1) {
- return growl.error(_t('admin_calendar.unable_to_remove_the_last_machine_of_the_slot_delete_the_slot_rather'));
+ return growl.error(_t('app.admin.calendar.unable_to_remove_the_last_machine_of_the_slot_delete_the_slot_rather'));
} else {
// open a confirmation dialog
return dialogs.confirm({
resolve: {
object () {
return {
- title: _t('admin_calendar.confirmation_required'),
- msg: _t('admin_calendar.do_you_really_want_to_remove_MACHINE_from_this_slot', { GENDER: getGender($scope.currentUser), MACHINE: machine.name }, 'messageformat') + ' ' +
- _t('admin_calendar.this_will_prevent_any_new_reservation_on_this_slot_but_wont_cancel_those_existing') + ' ' +
- _t('admin_calendar.beware_this_cannot_be_reverted')
+ title: _t('app.admin.calendar.confirmation_required'),
+ msg: _t('app.admin.calendar.do_you_really_want_to_remove_MACHINE_from_this_slot', { GENDER: getGender($scope.currentUser), MACHINE: machine.name }) + ' ' +
+ _t('app.admin.calendar.this_will_prevent_any_new_reservation_on_this_slot_but_wont_cancel_those_existing') + ' ' +
+ _t('app.admin.calendar.beware_this_cannot_be_reverted') + ''
};
}
}
@@ -150,16 +154,52 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
$scope.availability.title = data.title;
uiCalendarConfig.calendars.calendar.fullCalendar('rerenderEvents');
// notify the admin
- return growl.success(_t('admin_calendar.the_machine_was_successfully_removed_from_the_slot'));
+ return growl.success(_t('app.admin.calendar.the_machine_was_successfully_removed_from_the_slot'));
}
, function (data, status) { // failed
- growl.error(_t('admin_calendar.deletion_failed'));
+ growl.error(_t('app.admin.calendar.deletion_failed'));
}
);
});
}
};
+ /**
+ * Open a confirmation modal to remove a plan for the currently selected availability,
+ * @param plan {Object} must contain the machine ID and name
+ */
+ $scope.removePlan = function (plan) {
+ // open a confirmation dialog
+ return dialogs.confirm({
+ resolve: {
+ object () {
+ return {
+ title: _t('app.admin.calendar.confirmation_required'),
+ msg: _t('app.admin.calendar.do_you_really_want_to_remove_PLAN_from_this_slot', { GENDER: getGender($scope.currentUser), PLAN: plan.name })
+ };
+ }
+ }
+ },
+ function () {
+ // the admin has confirmed, remove the plan
+ const plans = _.drop($scope.availability.plan_ids, plan.id);
+
+ return Availability.update({ id: $scope.availability.id }, { availability: { plans_attributes: [{ id: plan.id, _destroy: true }] } }
+ , function (data, status) { // success
+ // update the plan_ids attribute
+ $scope.availability.plan_ids = data.plan_ids;
+ $scope.availability.plans = availabilityPlans();
+ uiCalendarConfig.calendars.calendar.fullCalendar('rerenderEvents');
+ // notify the admin
+ return growl.success(_t('app.admin.calendar.the_plan_was_successfully_removed_from_the_slot'));
+ }
+ , function (data, status) { // failed
+ growl.error(_t('app.admin.calendar.deletion_failed'));
+ }
+ );
+ });
+ };
+
/**
* Callback to alert the admin that the export request was acknowledged and is
* processing right now.
@@ -167,7 +207,7 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
$scope.alertExport = function (type) {
Export.status({ category: 'availabilities', type }).then(function (res) {
if (!res.data.exists) {
- return growl.success(_t('admin_calendar.export_is_running_you_ll_be_notified_when_its_ready'));
+ return growl.success(_t('app.admin.calendar.export_is_running_you_ll_be_notified_when_its_ready'));
}
});
};
@@ -195,8 +235,8 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
resolve: {
object () {
return {
- title: _t('admin_calendar.confirmation_required'),
- msg: locked ? _t('admin_calendar.do_you_really_want_to_allow_reservations') : _t('admin_calendar.do_you_really_want_to_block_this_slot')
+ title: _t('app.admin.calendar.confirmation_required'),
+ msg: locked ? _t('app.admin.calendar.do_you_really_want_to_allow_reservations') : _t('app.admin.calendar.do_you_really_want_to_block_this_slot')
};
}
}
@@ -208,18 +248,18 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
{ lock: !locked },
function (data) { // success
$scope.availability = data;
- growl.success(locked ? _t('admin_calendar.unlocking_success') : _t('admin_calendar.locking_success'));
+ growl.success(locked ? _t('app.admin.calendar.unlocking_success') : _t('app.admin.calendar.locking_success'));
uiCalendarConfig.calendars.calendar.fullCalendar('refetchEvents');
},
function (error) { // failed
- growl.error(locked ? _t('admin_calendar.unlocking_failed') : _t('admin_calendar.locking_failed'));
+ growl.error(locked ? _t('app.admin.calendar.unlocking_failed') : _t('app.admin.calendar.locking_failed'));
console.error(error);
}
);
}
);
} else {
- return growl.error(_t('admin_calendar.unlockable_because_reservations'));
+ return growl.error(_t('app.admin.calendar.unlockable_because_reservations'));
}
};
@@ -228,93 +268,195 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
*/
$scope.removeSlot = function () {
// open a confirmation dialog
- dialogs.confirm(
- {
- resolve: {
- object () {
- return {
- title: _t('admin_calendar.confirmation_required'),
- msg: _t('admin_calendar.do_you_really_want_to_delete_this_slot')
- };
- }
- }
- },
- function () {
- // the admin has confirmed, delete the slot
- Availability.delete(
- { id: $scope.availability.id },
- function () {
- uiCalendarConfig.calendars.calendar.fullCalendar('removeEvents', $scope.availability.id);
-
- growl.success(_t('admin_calendar.the_slot_START-END_has_been_successfully_deleted', { START: moment(event.start).format('LL LT'), END: moment(event.end).format('LT') }));
- $scope.availability = null;
- },
- function () {
- growl.error(_t('admin_calendar.unable_to_delete_the_slot_START-END_because_it_s_already_reserved_by_a_member', { START: moment(event.start).format('LL LT'), END: moment(event.end).format('LT') }));
- });
+ const modalInstance = $uibModal.open({
+ animation: true,
+ templateUrl: '<%= asset_path "admin/calendar/deleteRecurrent.html" %>',
+ size: 'md',
+ controller: 'DeleteRecurrentAvailabilityController',
+ resolve: {
+ availabilityPromise: ['Availability', function (Availability) { return Availability.get({ id: $scope.availability.id }).$promise; }]
}
- );
+ });
+ // once the dialog was closed, do things depending on the result
+ modalInstance.result.then(function (res) {
+ if (res.status == 'success') {
+ $scope.availability = null;
+ }
+ for (const availability of res.availabilities) {
+ uiCalendarConfig.calendars.calendar.fullCalendar('removeEvents', availability);
+ }
+ });
};
+ /**
+ * Setup the feature-tour for the admin/calendar page.
+ * This is intended as a contextual help (when pressing F1)
+ */
+ $scope.setupCalendarTour = function () {
+ // get the tour defined by the ui-tour directive
+ const uitour = uiTourService.getTourByName('calendar');
+ uitour.createStep({
+ selector: 'body',
+ stepId: 'welcome',
+ order: 0,
+ title: _t('app.admin.tour.calendar.welcome.title'),
+ content: _t('app.admin.tour.calendar.welcome.content'),
+ placement: 'bottom',
+ orphan: true
+ });
+ uitour.createStep({
+ selector: '.admin-calendar .fc-view-container',
+ stepId: 'agenda',
+ order: 1,
+ title: _t('app.admin.tour.calendar.agenda.title'),
+ content: _t('app.admin.tour.calendar.agenda.content'),
+ placement: 'right',
+ popupClass: 'width-350'
+ });
+ uitour.createStep({
+ selector: '.admin-calendar .export-xls-button',
+ stepId: 'export',
+ order: 2,
+ title: _t('app.admin.tour.calendar.export.title'),
+ content: _t('app.admin.tour.calendar.export.content'),
+ placement: 'left'
+ });
+ uitour.createStep({
+ selector: '.heading .import-ics-button',
+ stepId: 'import',
+ order: 3,
+ title: _t('app.admin.tour.calendar.import.title'),
+ content: _t('app.admin.tour.calendar.import.content'),
+ placement: 'left'
+ });
+ uitour.createStep({
+ selector: 'body',
+ stepId: 'conclusion',
+ order: 4,
+ title: _t('app.admin.tour.conclusion.title'),
+ content: _t('app.admin.tour.conclusion.content'),
+ placement: 'bottom',
+ orphan: true
+ });
+ // on tour end, save the status in database
+ uitour.on('ended', function () {
+ if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile.tours.indexOf('calendar') < 0) {
+ Member.completeTour({ id: $scope.currentUser.id }, { tour: 'calendar' }, function (res) {
+ $scope.currentUser.profile.tours = res.tours;
+ });
+ }
+ });
+ // if the user has never seen the tour, show him now
+ if (Fablab.featureTourDisplay !== 'manual' && $scope.currentUser.profile.tours.indexOf('calendar') < 0) {
+ uitour.start();
+ }
+ // start this tour when an user press F1 - this is contextual help
+ window.addEventListener('keydown', handleF1);
+ }
+
/* PRIVATE SCOPE */
+ /**
+ * Kind of constructor: these actions will be realized first when the controller is loaded
+ */
+ const initialize = function () {
+ // listen the $destroy event of the controller to remove the F1 key binding
+ $scope.$on('$destroy', function () {
+ window.removeEventListener('keydown', handleF1);
+ });
+ };
+
/**
* Return an enumerable meaninful string for the gender of the provider user
* @param user {Object} Database user record
* @return {string} 'male' or 'female'
*/
- var getGender = function (user) {
+ const getGender = function (user) {
if (user.statistic_profile) {
if (user.statistic_profile.gender === 'true') { return 'male'; } else { return 'female'; }
} else { return 'other'; }
};
+ /**
+ * Return a list of plans classified by group
+ *
+ * @returns {array}
+ */
+ var availabilityPlans = function() {
+ const plansClassifiedByGroup = [];
+ const _plans = _.filter(plansPromise, function (p) { return _.include($scope.availability.plan_ids, p.id) });
+ for (let group of Array.from(groupsPromise)) {
+ const groupObj = { id: group.id, name: group.name, plans: [] };
+ for (let plan of Array.from(_plans)) {
+ if (plan.group_id === group.id) { groupObj.plans.push(plan); }
+ }
+ if (groupObj.plans.length > 0) {
+ plansClassifiedByGroup.push(groupObj);
+ }
+ }
+ return plansClassifiedByGroup;
+ };
+
// Triggered when the admin drag on the agenda to create a new reservable slot.
// @see http://fullcalendar.io/docs/selection/select_callback/
//
- var calendarSelectCb = function (start, end, jsEvent, view) {
+ const calendarSelectCb = function (start, end, jsEvent, view) {
start = moment.tz(start.toISOString(), Fablab.timezone);
end = moment.tz(end.toISOString(), Fablab.timezone);
- // first we check that the selected slot is an N-hours multiple (ie. not decimal)
- if (Number.isInteger(parseInt((end.valueOf() - start.valueOf()) / (SLOT_MULTIPLE * 1000), 10) / SLOT_MULTIPLE)) {
- const today = new Date();
- if (parseInt((start.valueOf() - today) / (60 * 1000), 10) >= 0) {
- // then we open a modal window to let the admin specify the slot type
- const modalInstance = $uibModal.open({
- templateUrl: '<%= asset_path "admin/calendar/eventModal.html" %>',
- controller: 'CreateEventModalController',
- resolve: {
- start () { return start; },
- end () { return end; },
- machinesPromise: ['Machine', function (Machine) { return Machine.query().$promise; }],
- trainingsPromise: ['Training', function (Training) { return Training.query().$promise; }],
- spacesPromise: ['Space', function (Space) { return Space.query().$promise; }]
- } });
- // when the modal is closed, we send the slot to the server for saving
- modalInstance.result.then(
- function (availability) {
- uiCalendarConfig.calendars.calendar.fullCalendar(
- 'renderEvent',
- {
- id: availability.id,
- title: availability.title,
- start: availability.start_at,
- end: availability.end_at,
- textColor: 'black',
- backgroundColor: availability.backgroundColor,
- borderColor: availability.borderColor,
- tag_ids: availability.tag_ids,
- tags: availability.tags,
- machine_ids: availability.machine_ids
- },
- true
- );
- },
- function () { uiCalendarConfig.calendars.calendar.fullCalendar('unselect'); }
- );
- }
+
+ // check if slot is not in the past
+ const today = new Date();
+ if (Math.trunc((start.valueOf() - today) / (60 * 1000)) < 0) {
+ growl.warning(_t('app.admin.calendar.event_in_the_past'));
+ return uiCalendarConfig.calendars.calendar.fullCalendar('unselect');
}
+ // check that the selected slot is an multiple of SLOT_MULTIPLE (ie. not decimal)
+ const slots = Math.trunc((end.valueOf() - start.valueOf()) / (60 * 1000)) / SLOT_MULTIPLE;
+ if (!Number.isInteger(slots)) {
+ // otherwise, round it to upper decimal
+ const upper = Math.ceil(slots) * SLOT_MULTIPLE;
+ end = moment(start).add(upper, 'minutes');
+ }
+
+ // then we open a modal window to let the admin specify the slot type
+ const modalInstance = $uibModal.open({
+ templateUrl: '<%= asset_path "admin/calendar/eventModal.html" %>',
+ controller: 'CreateEventModalController',
+ resolve: {
+ start () { return start; },
+ end () { return end; },
+ machinesPromise: ['Machine', function (Machine) { return Machine.query().$promise; }],
+ trainingsPromise: ['Training', function (Training) { return Training.query().$promise; }],
+ spacesPromise: ['Space', function (Space) { return Space.query().$promise; }],
+ tagsPromise: ['Tag', function (Tag) { return Tag.query().$promise; }],
+ plansPromise: ['Plan', function (Plan) { return Plan.query().$promise; }],
+ groupsPromise: ['Group', function (Group) { return Group.query().$promise; }]
+ } });
+ // when the modal is closed, we send the slot to the server for saving
+ modalInstance.result.then(
+ function (availability) {
+ uiCalendarConfig.calendars.calendar.fullCalendar(
+ 'renderEvent',
+ {
+ id: availability.id,
+ title: availability.title,
+ start: availability.start_at,
+ end: availability.end_at,
+ textColor: 'black',
+ backgroundColor: availability.backgroundColor,
+ borderColor: availability.borderColor,
+ tag_ids: availability.tag_ids,
+ tags: availability.tags,
+ machine_ids: availability.machine_ids,
+ plan_ids: availability.plan_ids
+ },
+ true
+ );
+ },
+ function () { uiCalendarConfig.calendars.calendar.fullCalendar('unselect'); }
+ );
+
return uiCalendarConfig.calendars.calendar.fullCalendar('unselect');
};
@@ -322,8 +464,15 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
* Triggered when the admin clicks on a availability slot in the agenda.
* @see http://fullcalendar.io/docs/mouse/eventClick/
*/
- var calendarEventClickCb = function (event, jsEvent, view) {
+ const calendarEventClickCb = function (event, jsEvent, view) {
$scope.availability = event;
+ $scope.availability.plans = availabilityPlans();
+
+ if ($scope.availabilityDom) {
+ $scope.availabilityDom.classList.remove("fc-selected")
+ }
+ $scope.availabilityDom = jsEvent.target.closest('.fc-event');
+ $scope.availabilityDom.classList.add("fc-selected")
// if the user has clicked on the delete event button, delete the event
if ($(jsEvent.target).hasClass('remove-event')) {
@@ -339,8 +488,10 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
* Append the event tag into the block, just after the event title.
* @see http://fullcalendar.io/docs/event_rendering/eventRender/
*/
- var eventRenderCb = function (event, element) {
- element.find('.fc-content').prepend('x ');
+ const eventRenderCb = function (event, element) {
+ if (event.available_type !== 'event') {
+ element.find('.fc-content').prepend('x ');
+ }
if (event.tags.length > 0) {
let html = '';
for (let tag of Array.from(event.tags)) {
@@ -355,12 +506,38 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
* Triggered when resource fetching starts/stops.
* @see https://fullcalendar.io/docs/resource_data/loading/
*/
- return loadingCb = function (isLoading, view) {
+ const loadingCb = function (isLoading, view) {
if (isLoading) {
- // we remove existing events when fetching starts to prevent duplicates
- return uiCalendarConfig.calendars.calendar.fullCalendar('removeEvents');
+ // we remove existing events when fetching starts to prevent duplicates
+ uiCalendarConfig.calendars.calendar.fullCalendar('removeEvents');
+
}
};
+
+ /**
+ * Triggered when the view is changed
+ * @see https://fullcalendar.io/docs/v3/viewRender#v2
+ */
+ const viewRenderCb = function(view, element) {
+ // we unselect the current event to keep consistency
+ $scope.availability = null;
+ $scope.availabilityDom = null;
+ };
+
+ /**
+ * Callback used to trigger the feature tour when the user press the F1 key.
+ * @param e {KeyboardEvent}
+ */
+ const handleF1 = function (e) {
+ if (e.key === 'F1') {
+ e.preventDefault();
+ const tour = uiTourService.getTourByName('calendar');
+ if (tour) { tour.start(); }
+ }
+ };
+
+ // !!! MUST BE CALLED AT THE END of the controller
+ return initialize();
}
]);
@@ -368,9 +545,9 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
/**
* Controller used in the slot creation modal window
*/
-Application.Controllers.controller('CreateEventModalController', ['$scope', '$uibModalInstance', 'moment', 'start', 'end', 'machinesPromise', 'Availability', 'trainingsPromise', 'spacesPromise', 'Tag', 'growl', '_t',
- function ($scope, $uibModalInstance, moment, start, end, machinesPromise, Availability, trainingsPromise, spacesPromise, Tag, growl, _t) {
- // $uibModal parameter
+Application.Controllers.controller('CreateEventModalController', ['$scope', '$uibModalInstance', '$sce', 'moment', 'start', 'end', 'machinesPromise', 'Availability', 'trainingsPromise', 'spacesPromise', 'tagsPromise', 'plansPromise', 'groupsPromise', 'growl', '_t',
+ function ($scope, $uibModalInstance, $sce, moment, start, end, machinesPromise, Availability, trainingsPromise, spacesPromise, tagsPromise, plansPromise, groupsPromise, growl, _t) {
+ // $uibModal parameter
$scope.start = start;
// $uibModal parameter
@@ -385,8 +562,27 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui
// spaces list
$scope.spaces = spacesPromise.filter(function (s) { return !s.disabled; });
+ // all tags list
+ $scope.tags = tagsPromise;
+
+ $scope.isOnlySubscriptions = false;
+ $scope.selectedPlans = [];
+ $scope.selectedPlansBinding = {};
+ // list of plans, classified by group
+ $scope.plansClassifiedByGroup = [];
+ for (let group of Array.from(groupsPromise)) {
+ const groupObj = { id: group.id, name: group.name, plans: [] };
+ for (let plan of Array.from(plansPromise)) {
+ if (plan.group_id === group.id) { groupObj.plans.push(plan); }
+ }
+ if (groupObj.plans.length > 0) {
+ $scope.plansClassifiedByGroup.push(groupObj);
+ }
+ }
+
// machines associated with the created slot
$scope.selectedMachines = [];
+ $scope.selectedMachinesBinding = {};
// training associated with the created slot
$scope.selectedTraining = null;
@@ -416,9 +612,29 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui
$scope.availability = {
start_at: start,
end_at: end,
- available_type: 'machines' // default
+ available_type: 'machines', // default
+ tag_ids: [],
+ is_recurrent: false,
+ period: 'week',
+ nb_periods: 1,
+ end_date: undefined // recurrence end
};
+ // recurrent slots
+ $scope.occurrences = [];
+
+ // localized name(s) of the reservable item(s)
+ $scope.reservableName = '';
+
+ // localized name(s) of the selected tag(s)
+ $scope.tagsName = '';
+
+ // localized name(s) of the selected plan(s)
+ $scope.plansName = '';
+
+ // make the duration available for display
+ $scope.slotDuration = Fablab.slotDuration;
+
/**
* Adds or removes the provided machine from the current slot
* @param machine {Object}
@@ -432,6 +648,49 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui
}
};
+ /**
+ * Select/unselect all the machines
+ */
+ $scope.toggleAll = function() {
+ const count = $scope.selectedMachines.length;
+ $scope.selectedMachines = [];
+ $scope.selectedMachinesBinding = {};
+ if (count == 0) {
+ $scope.machines.forEach(function (machine) {
+ $scope.selectedMachines.push(machine);
+ $scope.selectedMachinesBinding[machine.id] = true;
+ })
+ }
+ }
+
+ /**
+ * Adds or removes the provided plan from the current slot
+ * @param plan {Object}
+ */
+ $scope.toggleSelectPlan = function (plan) {
+ const index = $scope.selectedPlans.indexOf(plan);
+ if (index > -1) {
+ return $scope.selectedPlans.splice(index, 1);
+ } else {
+ return $scope.selectedPlans.push(plan);
+ }
+ };
+
+ /**
+ * Select/unselect all the plans
+ */
+ $scope.toggleAllPlans = function() {
+ const count = $scope.selectedPlans.length;
+ $scope.selectedPlans = [];
+ $scope.selectedPlansBinding = {};
+ if (count == 0) {
+ plansPromise.forEach(function (plan) {
+ $scope.selectedPlans.push(plan);
+ $scope.selectedPlansBinding[plan.id] = true;
+ })
+ }
+ };
+
/**
* Callback for the modal window validation: save the slot and closes the modal
*/
@@ -440,7 +699,7 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui
if ($scope.selectedMachines.length > 0) {
$scope.availability.machine_ids = $scope.selectedMachines.map(function (m) { return m.id; });
} else {
- growl.error(_t('admin_calendar.you_should_select_at_least_a_machine'));
+ growl.error(_t('app.admin.calendar.you_should_select_at_least_a_machine'));
return;
}
} else if ($scope.availability.available_type === 'training') {
@@ -448,9 +707,16 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui
} else if ($scope.availability.available_type === 'space') {
$scope.availability.space_ids = [$scope.selectedSpace.id];
}
+ if ($scope.availability.is_recurrent) {
+ $scope.availability.occurrences = $scope.occurrences;
+ }
+ if ($scope.isOnlySubscriptions && $scope.selectedPlans.length > 0) {
+ $scope.availability.plan_ids = $scope.selectedPlans.map(function (p) { return p.id; });
+ }
return Availability.save(
- { availability: $scope.availability }
- , function (availability) { $uibModalInstance.close(availability); });
+ { availability: $scope.availability },
+ function (availability) { $uibModalInstance.close(availability); }
+ );
};
/**
@@ -458,6 +724,8 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui
*/
$scope.next = function () {
if ($scope.step === 1) { $scope.setNbTotalPlaces(); }
+ if ($scope.step === 2) { return validateSelection(); }
+ if ($scope.step === 5) { return validateRecurrence(); }
return $scope.step++;
};
@@ -495,23 +763,33 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui
$scope.selectedSpace = $scope.spaces[0];
}
- Tag.query().$promise.then(function (data) { $scope.tags = data; });
+ // when disable is only subscriptions option, reset all selected plans
+ $scope.$watch('isOnlySubscriptions', function(value) {
+ if (!value) {
+ $scope.selectedPlans = [];
+ $scope.selectedPlansBinding = {};
+ }
+ });
- // When we configure a machine availability, do not let the user change the end time, as the total
- // time must be dividable by 60 minutes (base slot duration). For training availabilities, the user
+ // When we configure a machine/space availability, do not let the user change the end time, as the total
+ // time must be dividable by Fablab.slotDuration minutes (base slot duration). For training availabilities, the user
// can configure any duration as it does not matters.
$scope.$watch('availability.available_type', function (newValue, oldValue, scope) {
if ((newValue === 'machines') || (newValue === 'space')) {
$scope.endDateReadOnly = true;
- const diff = moment($scope.end).diff($scope.start, 'hours'); // the result is rounded down by moment.js
- $scope.end = moment($scope.start).add(diff, 'hours').toDate();
+ const slots = Math.trunc(($scope.end.valueOf() - $scope.start.valueOf()) / (60 * 1000)) / Fablab.slotDuration;
+ if (!Number.isInteger(slots)) {
+ // otherwise, round it to upper decimal
+ const upper = Math.ceil(slots) * Fablab.slotDuration;
+ $scope.end = moment($scope.start).add(upper, 'minutes').toDate();
+ }
return $scope.availability.end_at = $scope.end;
} else {
return $scope.endDateReadOnly = false;
}
});
- // When the start date is changed, if we are configuring a machine availability,
+ // When the start date is changed, if we are configuring a machine/space availability,
// maintain the relative length of the slot (ie. change the end time accordingly)
$scope.$watch('start', function (newValue, oldValue, scope) {
// for machine or space availabilities, adjust the end time
@@ -520,27 +798,293 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui
end.add(moment(newValue).diff(oldValue), 'milliseconds');
$scope.end = end.toDate();
} else { // for training availabilities
- // prevent the admin from setting the begining after the and
- if (moment(newValue).add(1, 'hour').isAfter($scope.end)) {
+ // prevent the admin from setting the beginning after the end
+ if (moment(newValue).add(Fablab.slotDuration, 'minutes').isAfter($scope.end)) {
$scope.start = oldValue;
}
}
// update availability object
- return $scope.availability.start_at = $scope.start;
+ $scope.availability.start_at = $scope.start;
});
// Maintain consistency between the end time and the date object in the availability object
- return $scope.$watch('end', function (newValue, oldValue, scope) {
- // we prevent the admin from setting the end of the availability before its begining
- if (moment($scope.start).add(1, 'hour').isAfter(newValue)) {
+ $scope.$watch('end', function (newValue, oldValue, scope) {
+ // we prevent the admin from setting the end of the availability before its beginning
+ if (moment($scope.start).add(Fablab.slotDuration, 'minutes').isAfter(newValue)) {
$scope.end = oldValue;
}
// update availability object
- return $scope.availability.end_at = $scope.end;
+ $scope.availability.end_at = $scope.end;
});
};
+ /**
+ * Validates that a machine or more was/were selected before continuing to step 3 (adjust time + tags)
+ */
+ const validateSelection = function () {
+ if ($scope.availability.available_type === 'machines') {
+ if ($scope.selectedMachines.length === 0) {
+ return growl.error(_t('app.admin.calendar.you_should_select_at_least_a_machine'));
+ }
+ }
+ $scope.step++;
+ };
+
+ /**
+ * Validates that the recurrence parameters were correctly set before continuing to step 5 (summary)
+ */
+ const validateRecurrence = function () {
+ if ($scope.availability.is_recurrent) {
+ if (!$scope.availability.period) {
+ return growl.error(_t('app.admin.calendar.select_period'));
+ }
+ if (!$scope.availability.nb_periods || $scope.availability.nb_periods < 1) {
+ return growl.error(_t('app.admin.calendar.select_nb_period'));
+ }
+ if (!$scope.availability.end_date) {
+ return growl.error(_t('app.admin.calendar.select_end_date'));
+ }
+ }
+ // settings are ok
+ computeOccurrences();
+ computeNames();
+ $scope.step++;
+ };
+
+ /**
+ * Compute the various occurrences of the availability, according to the recurrence settings
+ */
+ const computeOccurrences = function () {
+ $scope.occurrences = [];
+
+ if ($scope.availability.is_recurrent) {
+ const date = moment($scope.availability.start_at);
+ const diff = moment($scope.availability.end_at).diff($scope.availability.start_at);
+ const end = moment($scope.availability.end_date).endOf('day');
+ while (date.isBefore(end)) {
+ const occur_end = moment(date).add(diff, 'ms');
+ $scope.occurrences.push({
+ start_at: date.toDate(),
+ end_at: occur_end.toDate()
+ });
+ date.add($scope.availability.nb_periods, $scope.availability.period);
+ }
+ } else {
+ $scope.occurrences.push({
+ start_at: $scope.availability.start_at,
+ end_at: $scope.availability.end_at
+ });
+ }
+ };
+
+ const computeNames = function () {
+ $scope.reservableName = '';
+ switch ($scope.availability.available_type) {
+ case 'machines':
+ $scope.reservableName = localizedList($scope.selectedMachines)
+ break;
+ case 'training':
+ $scope.reservableName = `${$scope.selectedTraining.name}`;
+ break;
+ case 'space':
+ $scope.reservableName = `${$scope.selectedSpace.name}`;
+ break;
+ default:
+ $scope.reservableName = `${_t("app.admin.calendar.none")}`;
+ }
+ const tags = $scope.tags.filter(function (t) {
+ return $scope.availability.tag_ids.indexOf(t.id) > -1;
+ })
+ $scope.tagsName = localizedList(tags);
+ if ($scope.isOnlySubscriptions && $scope.selectedPlans.length > 0) {
+ $scope.plansName = localizedList($scope.selectedPlans);
+ }
+ }
+
+ const localizedList = function (items) {
+ if (items.length === 0) return `${_t("app.admin.calendar.none")}`;
+
+ const names = items.map(function (i) { return $sce.trustAsHtml(`${i.name}`); });
+ if (items.length > 1) return names.slice(0, -1).join(', ') + ` ${_t('app.admin.calendar.and')} ` + names[names.length - 1];
+
+ return names[0];
+ }
+
// !!! MUST BE CALLED AT THE END of the controller
return initialize();
}
]);
+
+/**
+ * Controller used in the slot deletion modal window
+ */
+Application.Controllers.controller('DeleteRecurrentAvailabilityController', ['$scope', '$uibModalInstance', 'Availability', 'availabilityPromise', 'growl', '_t',
+ function ($scope, $uibModalInstance, Availability, availabilityPromise, growl, _t) {
+
+ // is the current slot (to be deleted) recurrent?
+ $scope.isRecurrent = availabilityPromise.is_recurrent;
+
+ // with recurrent slots: how many slots should we delete?
+ $scope.deleteMode = 'single';
+
+ /**
+ * Confirmation callback
+ */
+ $scope.ok = function () {
+ const { id, start_at, end_at } = availabilityPromise;
+ // the admin has confirmed, delete the slot
+ Availability.delete(
+ { id, mode: $scope.deleteMode },
+ function (res) {
+ // delete success
+ if (res.deleted > 1) {
+ growl.success(_t(
+ 'app.admin.calendar.slots_deleted',
+ {START: moment(start_at).format('LL LT'), COUNT: res.deleted - 1}
+ ));
+ } else {
+ growl.success(_t(
+ 'app.admin.calendar.slot_successfully_deleted',
+ {START: moment(start_at).format('LL LT'), END: moment(end_at).format('LT')}
+ ));
+ }
+ $uibModalInstance.close({
+ status: 'success',
+ availabilities: res.details.map(function (d) { return d.availability.id })
+ });
+ },
+ function (res) {
+ // not everything was deleted
+ const { data } = res;
+ if (data.total > 1) {
+ growl.warning(_t(
+ 'app.admin.calendar.slots_not_deleted',
+ {TOTAL: data.total, COUNT: data.total - data.deleted}
+ ));
+ } else {
+ growl.error(_t(
+ 'app.admin.calendar.unable_to_delete_the_slot',
+ {START: moment(start_at).format('LL LT'), END: moment(end_at).format('LT')}
+ ));
+ }
+ $uibModalInstance.close({
+ status: 'failed',
+ availabilities: data.details.filter(function (d) { return d.status }).map(function (d) { return d.availability.id })
+ });
+ });
+ }
+
+ /**
+ * Cancellation callback
+ */
+ $scope.cancel = function () {
+ $uibModalInstance.dismiss('cancel');
+ }
+ }
+]);
+
+
+
+/**
+ * Controller used in the iCalendar (ICS) imports management page
+ */
+
+Application.Controllers.controller('AdminICalendarController', ['$scope', 'iCalendars', 'ICalendar', 'dialogs', 'growl', '_t',
+ function ($scope, iCalendars, ICalendar, dialogs, growl, _t) {
+ // list of ICS sources
+ $scope.calendars = iCalendars;
+
+ // configuration of a new ICS source
+ $scope.newCalendar = {
+ color: undefined,
+ text_color: undefined,
+ url: undefined,
+ name: undefined,
+ text_hidden: false
+ };
+
+ /**
+ * Save the new iCalendar in database
+ */
+ $scope.save = function () {
+ ICalendar.save({}, { i_calendar: $scope.newCalendar }, function (data) {
+ // success
+ $scope.calendars.push(data);
+ $scope.newCalendar.url = undefined;
+ $scope.newCalendar.name = undefined;
+ $scope.newCalendar.color = null;
+ $scope.newCalendar.text_color = null;
+ $scope.newCalendar.text_hidden = false;
+ }, function (error) {
+ // failed
+ growl.error(_t('app.admin.icalendar.create_error'));
+ console.error(error);
+ })
+ }
+
+ /**
+ * Return a CSS-like style of the given calendar configuration
+ * @param calendar
+ */
+ $scope.calendarStyle = function (calendar) {
+ return {
+ 'border-color': calendar.color,
+ 'color': calendar.text_color,
+ 'width': calendar.text_hidden ? '50px' : 'auto',
+ 'height': calendar.text_hidden ? '21px' : 'auto'
+ };
+ }
+
+ /**
+ * Delete the given calendar from the database
+ * @param calendar
+ */
+ $scope.delete = function (calendar) {
+ dialogs.confirm(
+ {
+ resolve: {
+ object () {
+ return {
+ title: _t('app.admin.icalendar.confirmation_required'),
+ msg: _t('app.admin.icalendar.confirm_delete_import')
+ };
+ }
+ }
+ },
+ function () {
+ ICalendar.delete(
+ { id: calendar.id },
+ function () {
+ // success
+ const idx = $scope.calendars.indexOf(calendar);
+ $scope.calendars.splice(idx, 1);
+ growl.info(_t('app.admin.icalendar.delete_success'));
+ }, function (error) {
+ // failed
+ growl.error(_t('app.admin.icalendar.delete_failed'));
+ console.error(error);
+ }
+ );
+ }
+ )
+ }
+
+ /**
+ * Asynchronously re-fetches the events from the given calendar
+ * @param calendar
+ */
+ $scope.sync = function (calendar) {
+ ICalendar.sync(
+ { id: calendar.id },
+ function () {
+ // success
+ growl.info(_t('app.admin.icalendar.refresh'));
+ }, function (error) {
+ // failed
+ growl.error(_t('app.admin.icalendar.sync_failed'));
+ console.error(error);
+ }
+ )
+ }
+ }
+]);
diff --git a/app/assets/javascripts/controllers/admin/coupons.js b/app/assets/javascripts/controllers/admin/coupons.js
index be1490482..e4b27ef91 100644
--- a/app/assets/javascripts/controllers/admin/coupons.js
+++ b/app/assets/javascripts/controllers/admin/coupons.js
@@ -1,19 +1,8 @@
-/* eslint-disable
- no-return-assign,
- no-undef,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
/* COMMON CODE */
// The validity per user defines how many time a user may ba able to use the same coupon
// Here are the various options for this parameter
-const userValidities = ['once', 'forever'];
+const VALIDITIES = ['once', 'forever'];
/**
* Controller used in the coupon creation page
@@ -27,7 +16,7 @@ Application.Controllers.controller('NewCouponController', ['$scope', '$state', '
};
// Options for the validity per user
- $scope.validities = userValidities;
+ $scope.validities = VALIDITIES;
// Default parameters for AngularUI-Bootstrap datepicker (used for coupon validity limit selection)
$scope.datePicker = {
@@ -39,6 +28,13 @@ Application.Controllers.controller('NewCouponController', ['$scope', '$state', '
}
};
+ /**
+ * Return a localized human-readable name for the provided validity
+ */
+ $scope.validityName = function (validity) {
+ return _t(`app.shared.coupon.${validity}`);
+ };
+
/**
* Shows/hides the validity limit datepicker
* @param $event {Object} jQuery event object
@@ -46,17 +42,17 @@ Application.Controllers.controller('NewCouponController', ['$scope', '$state', '
$scope.toggleDatePicker = function ($event) {
$event.preventDefault();
$event.stopPropagation();
- return $scope.datePicker.opened = !$scope.datePicker.opened;
+ $scope.datePicker.opened = !$scope.datePicker.opened;
};
/**
* Callback to save the new coupon in $scope.coupon and redirect the user to the listing page
*/
- return $scope.saveCoupon = () =>
+ $scope.saveCoupon = () =>
Coupon.save({ coupon: $scope.coupon }, coupon => $state.go('app.admin.pricing')
, function (err) {
- growl.error(_t('unable_to_create_the_coupon_check_code_already_used'));
- return console.error(err);
+ growl.error(_t('app.admin.coupons_new.unable_to_create_the_coupon_check_code_already_used'));
+ console.error(err);
});
}
]);
@@ -75,7 +71,7 @@ Application.Controllers.controller('EditCouponController', ['$scope', '$state',
$scope.coupon = couponPromise;
// Options for the validity per user
- $scope.validities = userValidities;
+ $scope.validities = VALIDITIES;
// Mapping for validation errors
$scope.errors = {};
@@ -90,6 +86,13 @@ Application.Controllers.controller('EditCouponController', ['$scope', '$state',
}
};
+ /**
+ * Return a localized human-readable name for the provided validity
+ */
+ $scope.validityName = function (validity) {
+ return _t(`app.shared.coupon.${validity}`);
+ };
+
/**
* Shows/hides the validity limit datepicker
* @param $event {Object} jQuery event object
@@ -97,7 +100,7 @@ Application.Controllers.controller('EditCouponController', ['$scope', '$state',
$scope.toggleDatePicker = function ($event) {
$event.preventDefault();
$event.stopPropagation();
- return $scope.datePicker.opened = !$scope.datePicker.opened;
+ $scope.datePicker.opened = !$scope.datePicker.opened;
};
/**
@@ -105,10 +108,10 @@ Application.Controllers.controller('EditCouponController', ['$scope', '$state',
*/
$scope.updateCoupon = function () {
$scope.errors = {};
- return Coupon.update({ id: $scope.coupon.id }, { coupon: $scope.coupon }, coupon => $state.go('app.admin.pricing')
+ Coupon.update({ id: $scope.coupon.id }, { coupon: $scope.coupon }, coupon => $state.go('app.admin.pricing')
, function (err) {
- growl.error(_t('unable_to_update_the_coupon_an_error_occurred'));
- return $scope.errors = err.data;
+ growl.error(_t('app.admin.coupons_edit.unable_to_update_the_coupon_an_error_occurred'));
+ $scope.errors = err.data;
});
};
@@ -120,7 +123,7 @@ Application.Controllers.controller('EditCouponController', ['$scope', '$state',
const initialize = function () {
// parse the date if any
if (couponPromise.valid_until) {
- return $scope.coupon.valid_until = moment(couponPromise.valid_until).toDate();
+ $scope.coupon.valid_until = moment(couponPromise.valid_until).toDate();
}
};
diff --git a/app/assets/javascripts/controllers/admin/events.js.erb b/app/assets/javascripts/controllers/admin/events.js.erb
index 774111ed7..425e7af6d 100644
--- a/app/assets/javascripts/controllers/admin/events.js.erb
+++ b/app/assets/javascripts/controllers/admin/events.js.erb
@@ -56,14 +56,7 @@ class EventsController {
* @param content {Object} JSON - The upload's result
*/
$scope.submited = function (content) {
- if ((content.id == null)) {
- $scope.alerts = [];
- angular.forEach(content, function (v, k) {
- angular.forEach(v, function (err) { $scope.alerts.push({ msg: k + ': ' + err, type: 'danger' }); });
- });
- } else {
- $state.go('app.public.events_list');
- }
+ $scope.onSubmited(content);
};
/**
@@ -160,8 +153,8 @@ class EventsController {
/**
* Controller used in the events listing page (admin view)
*/
-Application.Controllers.controller('AdminEventsController', ['$scope', '$state', 'dialogs', '$uibModal', 'growl', 'Event', 'Category', 'EventTheme', 'AgeRange', 'PriceCategory', 'eventsPromise', 'categoriesPromise', 'themesPromise', 'ageRangesPromise', 'priceCategoriesPromise', '_t',
- function ($scope, $state, dialogs, $uibModal, growl, Event, Category, EventTheme, AgeRange, PriceCategory, eventsPromise, categoriesPromise, themesPromise, ageRangesPromise, priceCategoriesPromise, _t) {
+Application.Controllers.controller('AdminEventsController', ['$scope', '$state', 'dialogs', '$uibModal', 'growl', 'Event', 'Category', 'EventTheme', 'AgeRange', 'PriceCategory', 'eventsPromise', 'categoriesPromise', 'themesPromise', 'ageRangesPromise', 'priceCategoriesPromise', '_t', 'Member', 'uiTourService',
+ function ($scope, $state, dialogs, $uibModal, growl, Event, Category, EventTheme, AgeRange, PriceCategory, eventsPromise, categoriesPromise, themesPromise, ageRangesPromise, priceCategoriesPromise, _t, Member, uiTourService) {
/* PUBLIC SCOPE */
// By default, the pagination mode is activated to limit the page size
@@ -196,6 +189,9 @@ Application.Controllers.controller('AdminEventsController', ['$scope', '$state',
$scope.eventsScope =
{ selected: '' };
+ // default tab: events list
+ $scope.tabs = { active: 0 };
+
/**
* Adds a bucket of events to the bottom of the page, grouped by month
*/
@@ -228,26 +224,26 @@ Application.Controllers.controller('AdminEventsController', ['$scope', '$state',
*/
$scope.removeElement = function (model, index) {
if ((model === 'category') && (getModel(model)[1].length === 1)) {
- growl.error(_t('at_least_one_category_is_required') + ' ' + _t('unable_to_delete_the_last_one'));
+ growl.error(_t('app.admin.events.at_least_one_category_is_required') + ' ' + _t('app.admin.events.unable_to_delete_the_last_one'));
return false;
}
if (getModel(model)[1][index].related_to > 0) {
- growl.error(_t('unable_to_delete_ELEMENT_already_in_use_NUMBER_times', { ELEMENT: model, NUMBER: getModel(model)[1][index].related_to }, 'messageformat'));
+ growl.error(_t('app.admin.events.unable_to_delete_ELEMENT_already_in_use_NUMBER_times', { ELEMENT: model, NUMBER: getModel(model)[1][index].related_to }));
return false;
}
return dialogs.confirm({
resolve: {
object () {
return {
- title: _t('confirmation_required'),
- msg: _t('do_you_really_want_to_delete_this_ELEMENT', { ELEMENT: model }, 'messageformat')
+ title: _t('app.admin.events.confirmation_required'),
+ msg: _t('app.admin.events.do_you_really_want_to_delete_this_ELEMENT', { ELEMENT: model })
};
}
}
}
, function () { // delete confirmed
getModel(model)[0].delete(getModel(model)[1][index], null, function () { getModel(model)[1].splice(index, 1); }
- , function () { growl.error(_t('unable_to_delete_an_error_occured')); });
+ , function () { growl.error(_t('app.admin.events.unable_to_delete_an_error_occured')); });
});
};
@@ -292,10 +288,10 @@ Application.Controllers.controller('AdminEventsController', ['$scope', '$state',
// save the price category to the API
PriceCategory.save(p_cat, function (cat) {
$scope.priceCategories.push(cat);
- return growl.success(_t('price_category_successfully_created'));
+ return growl.success(_t('app.admin.events.price_category_successfully_created'));
}
, function (err) {
- growl.error(_t('unable_to_add_the_price_category_check_name_already_used'));
+ growl.error(_t('app.admin.events.unable_to_add_the_price_category_check_name_already_used'));
return console.error(err);
});
});
@@ -308,7 +304,7 @@ Application.Controllers.controller('AdminEventsController', ['$scope', '$state',
*/
$scope.editPriceCategory = function (id, index) {
if ($scope.priceCategories[index].id !== id) {
- return growl.error(_t('unexpected_error_occurred_please_refresh'));
+ return growl.error(_t('app.admin.events.unexpected_error_occurred_please_refresh'));
} else {
return $uibModal.open({
templateUrl: '<%= asset_path "admin/events/price_form.html" %>',
@@ -320,10 +316,10 @@ Application.Controllers.controller('AdminEventsController', ['$scope', '$state',
// update the price category to the API
PriceCategory.update({ id }, { price_category: p_cat }, function (cat) {
$scope.priceCategories[index] = cat;
- return growl.success(_t('price_category_successfully_updated'));
+ return growl.success(_t('app.admin.events.price_category_successfully_updated'));
}
, function (err) {
- growl.error(_t('unable_to_update_the_price_category'));
+ growl.error(_t('app.admin.events.unable_to_update_the_price_category'));
return console.error(err);
});
});
@@ -337,17 +333,17 @@ Application.Controllers.controller('AdminEventsController', ['$scope', '$state',
*/
$scope.removePriceCategory = function (id, index) {
if ($scope.priceCategories[index].id !== id) {
- return growl.error(_t('unexpected_error_occurred_please_refresh'));
+ return growl.error(_t('app.admin.events.unexpected_error_occurred_please_refresh'));
} else if ($scope.priceCategories[index].events > 0) {
- return growl.error(_t('unable_to_delete_this_price_category_because_it_is_already_used'));
+ return growl.error(_t('app.admin.events.unable_to_delete_this_price_category_because_it_is_already_used'));
} else {
return dialogs.confirm(
{
resolve: {
object () {
return {
- title: _t('confirmation_required'),
- msg: _t('do_you_really_want_to_delete_this_price_category')
+ title: _t('app.admin.events.confirmation_required'),
+ msg: _t('app.admin.events.do_you_really_want_to_delete_this_price_category')
};
}
}
@@ -356,10 +352,10 @@ Application.Controllers.controller('AdminEventsController', ['$scope', '$state',
PriceCategory.remove(
{ id },
function () { // successfully deleted
- growl.success(_t('price_category_successfully_deleted'));
+ growl.success(_t('app.admin.events.price_category_successfully_deleted'));
$scope.priceCategories.splice(index, 1);
},
- function () { growl.error(_t('price_category_deletion_failed')); }
+ function () { growl.error(_t('app.admin.events.price_category_deletion_failed')); }
);
}
);
@@ -378,12 +374,127 @@ Application.Controllers.controller('AdminEventsController', ['$scope', '$state',
return $scope.page = 1;
};
+
+ /**
+ * Setup the feature-tour for the admin/events page.
+ * This is intended as a contextual help (when pressing F1)
+ */
+ $scope.setupEventsTour = function () {
+ // get the tour defined by the ui-tour directive
+ const uitour = uiTourService.getTourByName('events');
+ uitour.createStep({
+ selector: 'body',
+ stepId: 'welcome',
+ order: 0,
+ title: _t('app.admin.tour.events.welcome.title'),
+ content: _t('app.admin.tour.events.welcome.content'),
+ placement: 'bottom',
+ orphan: true
+ });
+ uitour.createStep({
+ selector: '.events-management .events-list',
+ stepId: 'list',
+ order: 1,
+ title: _t('app.admin.tour.events.list.title'),
+ content: _t('app.admin.tour.events.list.content'),
+ placement: 'top'
+ });
+ uitour.createStep({
+ selector: '.events-management .events-list-filter',
+ stepId: 'filter',
+ order: 2,
+ title: _t('app.admin.tour.events.filter.title'),
+ content: _t('app.admin.tour.events.filter.content'),
+ placement: 'bottom'
+ });
+ uitour.createStep({
+ selector: '.events-management .events-categories',
+ stepId: 'categories',
+ order: 3,
+ title: _t('app.admin.tour.events.categories.title'),
+ content: _t('app.admin.tour.events.categories.content'),
+ placement: 'bottom'
+ });
+ uitour.createStep({
+ selector: '.events-management .events-themes',
+ stepId: 'themes',
+ order: 4,
+ title: _t('app.admin.tour.events.themes.title'),
+ content: _t('app.admin.tour.events.themes.content'),
+ placement: 'top'
+ });
+ uitour.createStep({
+ selector: '.events-management .events-age-ranges',
+ stepId: 'ages',
+ order: 5,
+ title: _t('app.admin.tour.events.ages.title'),
+ content: _t('app.admin.tour.events.ages.content'),
+ placement: 'top'
+ });
+ uitour.createStep({
+ selector: '.events-management .prices-tab',
+ stepId: 'prices',
+ order: 6,
+ title: _t('app.admin.tour.events.prices.title'),
+ content: _t('app.admin.tour.events.prices.content'),
+ placement: 'bottom'
+ });
+ uitour.createStep({
+ selector: 'body',
+ stepId: 'conclusion',
+ order: 7,
+ title: _t('app.admin.tour.conclusion.title'),
+ content: _t('app.admin.tour.conclusion.content'),
+ placement: 'bottom',
+ orphan: true
+ });
+ // on step change, change the active tab if needed
+ uitour.on('stepChanged', function (nextStep) {
+ if (nextStep.stepId === 'list' || nextStep.stepId === 'filter') { $scope.tabs.active = 0; }
+ if (nextStep.stepId === 'categories' || nextStep.stepId === 'ages') { $scope.tabs.active = 1; }
+ if (nextStep.stepId === 'prices') { $scope.tabs.active = 2; }
+ });
+ // on tour end, save the status in database
+ uitour.on('ended', function () {
+ if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile.tours.indexOf('events') < 0) {
+ Member.completeTour({ id: $scope.currentUser.id }, { tour: 'events' }, function (res) {
+ $scope.currentUser.profile.tours = res.tours;
+ });
+ }
+ });
+ // if the user has never seen the tour, show him now
+ if (Fablab.featureTourDisplay !== 'manual' && $scope.currentUser.profile.tours.indexOf('events') < 0) {
+ uitour.start();
+ }
+ // start this tour when an user press F1 - this is contextual help
+ window.addEventListener('keydown', handleF1);
+ }
+
/* PRIVATE SCOPE */
/**
* Kind of constructor: these actions will be realized first when the controller is loaded
*/
- const initialize = function () { paginationCheck(eventsPromise, $scope.events); };
+ const initialize = function () {
+ paginationCheck(eventsPromise, $scope.events);
+
+ // listen the $destroy event of the controller to remove the F1 key binding
+ $scope.$on('$destroy', function () {
+ window.removeEventListener('keydown', handleF1);
+ });
+ };
+
+ /**
+ * Callback used to trigger the feature tour when the user press the F1 key.
+ * @param e {KeyboardEvent}
+ */
+ const handleF1 = function (e) {
+ if (e.key === 'F1') {
+ e.preventDefault();
+ const tour = uiTourService.getTourByName('events');
+ if (tour) { tour.start(); }
+ }
+ };
/**
* Check if all events are already displayed OR if the button 'load more events'
@@ -391,7 +502,7 @@ Application.Controllers.controller('AdminEventsController', ['$scope', '$state',
* @param lastEvents {Array} last events loaded onto the diplay (ie. last "page")
* @param events {Array} full list of events displayed on the page (not only the last retrieved)
*/
- var paginationCheck = function (lastEvents, events) {
+ const paginationCheck = function (lastEvents, events) {
if (lastEvents.length > 0) {
if (events.length >= lastEvents[0].nb_total_events) {
return $scope.paginateActive = false;
@@ -408,7 +519,7 @@ Application.Controllers.controller('AdminEventsController', ['$scope', '$state',
* @param name {string} 'category', 'theme' or 'age_range'
* @return {[Object, Array]} model and datastore
*/
- var getModel = function (name) {
+ const getModel = function (name) {
switch (name) {
case 'category': return [Category, $scope.categories];
case 'theme': return [EventTheme, $scope.themes];
@@ -431,7 +542,16 @@ Application.Controllers.controller('ShowEventReservationsController', ['$scope',
$scope.event = eventPromise;
// list of reservations for the current event
- return $scope.reservations = reservationsPromise;
+ $scope.reservations = reservationsPromise;
+
+ /**
+ * Test if the provided reservation has been cancelled
+ * @param reservation {Reservation}
+ * @returns {boolean}
+ */
+ $scope.isCancelled = function(reservation) {
+ return !!(reservation.slots[0].canceled_at);
+ }
}]);
/**
@@ -474,13 +594,25 @@ Application.Controllers.controller('NewEventController', ['$scope', '$state', 'C
// Possible types of recurrences for an event
$scope.recurrenceTypes = [
- { label: _t('none'), value: 'none' },
- { label: _t('every_days'), value: 'day' },
- { label: _t('every_week'), value: 'week' },
- { label: _t('every_month'), value: 'month' },
- { label: _t('every_year'), value: 'year' }
+ { label: _t('app.admin.events_new.none'), value: 'none' },
+ { label: _t('app.admin.events_new.every_days'), value: 'day' },
+ { label: _t('app.admin.events_new.every_week'), value: 'week' },
+ { label: _t('app.admin.events_new.every_month'), value: 'month' },
+ { label: _t('app.admin.events_new.every_year'), value: 'year' }
];
+ // triggered when the new event form was submitted to the API and have received an answer
+ $scope.onSubmited = function(content) {
+ if ((content.id == null)) {
+ $scope.alerts = [];
+ angular.forEach(content, function (v, k) {
+ angular.forEach(v, function (err) { $scope.alerts.push({ msg: k + ': ' + err, type: 'danger' }); });
+ });
+ } else {
+ $state.go('app.public.events_list');
+ }
+ };
+
// Using the EventsController
return new EventsController($scope, $state);
}
@@ -489,9 +621,9 @@ Application.Controllers.controller('NewEventController', ['$scope', '$state', 'C
/**
* Controller used in the events edition page
*/
-Application.Controllers.controller('EditEventController', ['$scope', '$state', '$stateParams', 'CSRF', 'eventPromise', 'categoriesPromise', 'themesPromise', 'ageRangesPromise', 'priceCategoriesPromise',
- function ($scope, $state, $stateParams, CSRF, eventPromise, categoriesPromise, themesPromise, ageRangesPromise, priceCategoriesPromise) {
- /* PUBLIC SCOPE */
+Application.Controllers.controller('EditEventController', ['$scope', '$state', '$stateParams', 'CSRF', 'eventPromise', 'categoriesPromise', 'themesPromise', 'ageRangesPromise', 'priceCategoriesPromise', '$uibModal', 'growl', '_t',
+ function ($scope, $state, $stateParams, CSRF, eventPromise, categoriesPromise, themesPromise, ageRangesPromise, priceCategoriesPromise, $uibModal, growl, _t) {
+ /* PUBLIC SCOPE */
// API URL where the form will be posted
$scope.actionUrl = `/api/events/${$stateParams.id}`;
@@ -502,6 +634,9 @@ Application.Controllers.controller('EditEventController', ['$scope', '$state', '
// Retrieve the event details, in case of error the user is redirected to the events listing
$scope.event = eventPromise;
+ // We'll keep track of the initial dates here, for later comparison
+ $scope.initialDates = {};
+
// List of categories for the events
$scope.categories = categoriesPromise;
@@ -514,6 +649,83 @@ Application.Controllers.controller('EditEventController', ['$scope', '$state', '
// List of age ranges
$scope.ageRanges = ageRangesPromise;
+ // Default edit-mode for periodic event
+ $scope.editMode = 'single';
+
+ // show edit-mode modal if event is recurrent
+ $scope.isShowEditModeModal = $scope.event.recurrence_events.length > 0;
+
+ $scope.editRecurrent = function (e) {
+ if ($scope.isShowEditModeModal && $scope.event.recurrence_events.length > 0) {
+ e.preventDefault();
+
+ // open a choice edit-mode dialog
+ const modalInstance = $uibModal.open({
+ animation: true,
+ templateUrl: '<%= asset_path "events/editRecurrent.html" %>',
+ size: 'md',
+ controller: 'EditRecurrentEventController',
+ resolve: {
+ editMode: function () { return $scope.editMode; },
+ initialDates: function () { return $scope.initialDates; },
+ currentEvent: function () { return $scope.event; }
+ }
+ });
+ // submit form event by edit-mode
+ modalInstance.result.then(function(res) {
+ $scope.isShowEditModeModal = false;
+ $scope.editMode = res.editMode;
+ e.target.click();
+ });
+ }
+ };
+
+ // triggered when the edit event form was submitted to the API and have received an answer
+ $scope.onSubmited = function(data) {
+ if (data.total === data.updated) {
+ if (data.updated > 1) {
+ growl.success(_t(
+ 'app.admin.events_edit.events_updated',
+ {COUNT: data.updated - 1}
+ ));
+ } else {
+ growl.success(_t(
+ 'app.admin.events_edit.event_successfully_updated'
+ ));
+ }
+ } else {
+ if (data.total > 1) {
+ growl.warning(_t(
+ 'app.admin.events_edit.events_not_updated',
+ {TOTAL: data.total, COUNT: data.total - data.updated}
+ ));
+ if (_.find(data.details, { error: 'EventPriceCategory' })) {
+ growl.error(_t(
+ 'app.admin.events_edit.error_deleting_reserved_price'
+ ));
+ } else {
+ growl.error(_t(
+ 'app.admin.events_edit.other_error'
+ ));
+ }
+ } else {
+ growl.error(_t(
+ 'app.admin.events_edit.unable_to_update_the_event'
+ ));
+ if (data.details[0].error === 'EventPriceCategory') {
+ growl.error(_t(
+ 'app.admin.events_edit.error_deleting_reserved_price'
+ ));
+ } else {
+ growl.error(_t(
+ 'app.admin.events_edit.other_error'
+ ));
+ }
+ }
+ }
+ $state.go('app.public.events_list');
+ };
+
/* PRIVATE SCOPE */
/**
@@ -526,6 +738,11 @@ Application.Controllers.controller('EditEventController', ['$scope', '$state', '
$scope.event.start_date = moment($scope.event.start_date).toDate();
$scope.event.end_date = moment($scope.event.end_date).toDate();
+ $scope.initialDates = {
+ start: new Date($scope.event.start_date.valueOf()),
+ end: new Date($scope.event.end_date.valueOf())
+ };
+
// Using the EventsController
return new EventsController($scope, $state);
};
@@ -534,3 +751,36 @@ Application.Controllers.controller('EditEventController', ['$scope', '$state', '
return initialize();
}
]);
+
+/**
+ * Controller used in the event edit-mode modal window
+ */
+Application.Controllers.controller('EditRecurrentEventController', ['$scope', '$uibModalInstance', 'editMode', 'growl', 'initialDates', 'currentEvent', '_t',
+ function ($scope, $uibModalInstance, editMode, growl, initialDates, currentEvent, _t) {
+ // with recurrent slots: how many slots should we update?
+ $scope.editMode = editMode;
+
+ /**
+ * Confirmation callback
+ */
+ $scope.ok = function () {
+ $uibModalInstance.close({
+ editMode: $scope.editMode
+ });
+ }
+
+ /**
+ * Test if any of the dates of the event has changed
+ */
+ $scope.hasDateChanged = function() {
+ return (!moment(initialDates.start).isSame(currentEvent.start_date, 'day') || !moment(initialDates.end).isSame(currentEvent.end_date, 'day'));
+ }
+
+ /**
+ * Cancellation callback
+ */
+ $scope.cancel = function () {
+ $uibModalInstance.dismiss('cancel');
+ }
+ }
+]);
diff --git a/app/assets/javascripts/controllers/admin/graphs.js b/app/assets/javascripts/controllers/admin/graphs.js
index e0448f35f..3883405e0 100644
--- a/app/assets/javascripts/controllers/admin/graphs.js
+++ b/app/assets/javascripts/controllers/admin/graphs.js
@@ -25,10 +25,10 @@ Application.Controllers.controller('GraphsController', ['$scope', '$state', '$ro
const CHART_HEIGHT = 500;
// Label of the charts' horizontal axes
- const X_AXIS_LABEL = _t('date');
+ const X_AXIS_LABEL = _t('app.admin.stats_graphs.date');
// Label of the charts' vertical axes
- const Y_AXIS_LABEL = _t('number');
+ const Y_AXIS_LABEL = _t('app.admin.stats_graphs.number');
// Colors for the line charts. Each new line uses the next color in this array
const CHART_COLORS = ['#b35a94', '#1c5794', '#00b49e', '#6fac48', '#ebcf4a', '#fd7e33', '#ca3436', '#a26e3a'];
@@ -193,9 +193,9 @@ Application.Controllers.controller('GraphsController', ['$scope', '$state', '$ro
}
} else if ($scope.display.interval === 'week') {
if ((typeof x === 'number') || d instanceof Date) {
- return d3.time.format(_t('week_short') + ' %U')(moment(d).toDate());
+ return d3.time.format(_t('app.admin.stats_graphs.week_short') + ' %U')(moment(d).toDate());
} else if (typeof d === 'number') {
- return _t('week_of_START_to_END', { START: moment(d).format('L'), END: moment(d).add(6, 'days').format('L') });
+ return _t('app.admin.stats_graphs.week_of_START_to_END', { START: moment(d).format('L'), END: moment(d).add(6, 'days').format('L') });
} else { // typeof d == 'string'
return d;
}
@@ -653,7 +653,7 @@ Application.Controllers.controller('GraphsController', ['$scope', '$state', '$ro
// common for each charts
chart.margin({ left: 100, right: 100 });
- chart.noData(_t('no_data_for_this_period'));
+ chart.noData(_t('app.admin.stats_graphs.no_data_for_this_period'));
chart.height(CHART_HEIGHT);
// add new chart to the page
diff --git a/app/assets/javascripts/controllers/admin/groups.js b/app/assets/javascripts/controllers/admin/groups.js
index 1f174ba5b..e102117c6 100644
--- a/app/assets/javascripts/controllers/admin/groups.js
+++ b/app/assets/javascripts/controllers/admin/groups.js
@@ -54,15 +54,15 @@ Application.Controllers.controller('GroupsController', ['$scope', 'groupsPromise
*/
$scope.saveGroup = function (data, id) {
if (id != null) {
- return Group.update({ id }, { group: data }, response => growl.success(_t('group_form.changes_successfully_saved'))
- , error => growl.error(_t('group_form.an_error_occurred_while_saving_changes')));
+ return Group.update({ id }, { group: data }, response => growl.success(_t('app.admin.members.group_form.changes_successfully_saved'))
+ , error => growl.error(_t('app.admin.members.group_form.an_error_occurred_while_saving_changes')));
} else {
return Group.save({ group: data }, function (resp) {
- growl.success(_t('group_form.new_group_successfully_saved'));
+ growl.success(_t('app.admin.members.group_form.new_group_successfully_saved'));
return $scope.groups[$scope.groups.length - 1].id = resp.id;
}
, function (error) {
- growl.error(_t('.group_forman_error_occurred_when_saving_the_new_group'));
+ growl.error(_t('app.admin.members.group_form.an_error_occurred_when_saving_the_new_group'));
return $scope.groups.splice($scope.groups.length - 1, 1);
});
}
@@ -74,10 +74,10 @@ Application.Controllers.controller('GroupsController', ['$scope', 'groupsPromise
*/
$scope.removeGroup = index =>
Group.delete({ id: $scope.groups[index].id }, function (resp) {
- growl.success(_t('group_form.group_successfully_deleted'));
+ growl.success(_t('app.admin.members.group_form.group_successfully_deleted'));
return $scope.groups.splice(index, 1);
}
- , error => growl.error(_t('group_form.unable_to_delete_group_because_some_users_and_or_groups_are_still_linked_to_it')));
+ , error => growl.error(_t('app.admin.members.group_form.unable_to_delete_group_because_some_users_and_or_groups_are_still_linked_to_it')));
/**
* Enable/disable the group at the specified index
@@ -86,13 +86,13 @@ Application.Controllers.controller('GroupsController', ['$scope', 'groupsPromise
return $scope.toggleDisableGroup = function (index) {
const group = $scope.groups[index];
if (!group.disabled && (group.users > 0)) {
- return growl.error(_t('group_form.unable_to_disable_group_with_users', { USERS: group.users }, 'messageformat'));
+ return growl.error(_t('app.admin.members.group_form.unable_to_disable_group_with_users', { USERS: group.users }));
} else {
return Group.update({ id: group.id }, { group: { disabled: !group.disabled } }, function (response) {
$scope.groups[index] = response;
- return growl.success(_t('group_form.group_successfully_enabled_disabled', { STATUS: response.disabled }, 'messageformat'));
+ return growl.success(_t('app.admin.members.group_form.group_successfully_enabled_disabled', { STATUS: response.disabled }));
}
- , error => growl.error(_t('group_form.unable_to_enable_disable_group', { STATUS: !group.disabled }, 'messageformat')));
+ , error => growl.error(_t('app.admin.members.group_form.unable_to_enable_disable_group', { STATUS: !group.disabled })));
}
};
}
diff --git a/app/assets/javascripts/controllers/admin/invoices.js.erb b/app/assets/javascripts/controllers/admin/invoices.js.erb
index 1fa010392..9c7a72356 100644
--- a/app/assets/javascripts/controllers/admin/invoices.js.erb
+++ b/app/assets/javascripts/controllers/admin/invoices.js.erb
@@ -17,8 +17,8 @@
/**
* Controller used in the admin invoices listing page
*/
-Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'Invoice', 'AccountingPeriod', 'invoices', 'closedPeriods', '$uibModal', 'growl', '$filter', 'Setting', 'settings', '_t',
- function ($scope, $state, Invoice, AccountingPeriod, invoices, closedPeriods, $uibModal, growl, $filter, Setting, settings, _t) {
+Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'Invoice', 'AccountingPeriod', 'invoices', 'closedPeriods', '$uibModal', 'growl', '$filter', 'Setting', 'settings', '_t', 'Member', 'uiTourService',
+ function ($scope, $state, Invoice, AccountingPeriod, invoices, closedPeriods, $uibModal, growl, $filter, Setting, settings, _t, Member, uiTourService) {
/* PRIVATE STATIC CONSTANTS */
// number of invoices loaded each time we click on 'load more...'
@@ -28,11 +28,10 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
// default active tab
$scope.tabs = {
- listing: { active: !Fablab.withoutInvoices },
- settings: { active: Fablab.withoutInvoices }
+ active: Fablab.withoutInvoices ? 1 : 0
};
- // List of all users invoices
+ // List of all invoices
$scope.invoices = invoices;
// Invoices filters
@@ -215,7 +214,7 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
$scope.invoices.unshift(res.avoir);
return Invoice.get({ id: invoice.id }, function (data) {
invoice.has_avoir = data.has_avoir;
- return growl.success(_t('invoices.refund_invoice_successfully_created'));
+ return growl.success(_t('app.admin.invoices.refund_invoice_successfully_created'));
});
});
};
@@ -246,7 +245,7 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
};
/**
- * Generate an order nmuber sample from the parametrized model
+ * Generate an order number sample from the parametrized model
* @returns {string} invoice reference sample
*/
$scope.mkNumber = function () {
@@ -289,10 +288,10 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
modalInstance.result.then(function (model) {
Setting.update({ name: 'invoice_reference' }, { value: model }, function (data) {
$scope.invoice.reference.model = model;
- growl.success(_t('invoices.invoice_reference_successfully_saved'));
+ growl.success(_t('app.admin.invoices.invoice_reference_successfully_saved'));
}
, function (error) {
- growl.error(_t('invoices.an_error_occurred_while_saving_invoice_reference'));
+ growl.error(_t('app.admin.invoices.an_error_occurred_while_saving_invoice_reference'));
console.error(error);
});
});
@@ -327,24 +326,24 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
Setting.update({ name: 'invoice_code-value' }, { value: result.model }, function (data) {
$scope.invoice.code.model = result.model;
if (result.active) {
- return growl.success(_t('invoices.invoicing_code_succesfully_saved'));
+ return growl.success(_t('app.admin.invoices.invoicing_code_succesfully_saved'));
}
}
, function (error) {
- growl.error(_t('invoices.an_error_occurred_while_saving_the_invoicing_code'));
+ growl.error(_t('app.admin.invoices.an_error_occurred_while_saving_the_invoicing_code'));
return console.error(error);
});
return Setting.update({ name: 'invoice_code-active' }, { value: result.active ? 'true' : 'false' }, function (data) {
$scope.invoice.code.active = result.active;
if (result.active) {
- return growl.success(_t('invoices.code_successfully_activated'));
+ return growl.success(_t('app.admin.invoices.code_successfully_activated'));
} else {
- return growl.success(_t('invoices.code_successfully_disabled'));
+ return growl.success(_t('app.admin.invoices.code_successfully_disabled'));
}
}
, function (error) {
- growl.error(_t('invoices.an_error_occurred_while_activating_the_invoicing_code'));
+ growl.error(_t('app.admin.invoices.an_error_occurred_while_activating_the_invoicing_code'));
return console.error(error);
});
});
@@ -373,10 +372,10 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
return modalInstance.result.then(function (model) {
Setting.update({ name: 'invoice_order-nb' }, { value: model }, function (data) {
$scope.invoice.number.model = model;
- return growl.success(_t('invoices.order_number_successfully_saved'));
+ return growl.success(_t('app.admin.invoices.order_number_successfully_saved'));
}
, function (error) {
- growl.error(_t('invoices.an_error_occurred_while_saving_the_order_number'));
+ growl.error(_t('app.admin.invoices.an_error_occurred_while_saving_the_order_number'));
return console.error(error);
});
});
@@ -431,24 +430,24 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
Setting.update({ name: 'invoice_VAT-rate' }, { value: result.rate + '' }, function (data) {
$scope.invoice.VAT.rate = result.rate;
if (result.active) {
- return growl.success(_t('invoices.VAT_rate_successfully_saved'));
+ return growl.success(_t('app.admin.invoices.VAT_rate_successfully_saved'));
}
}
, function (error) {
- growl.error(_t('invoices.an_error_occurred_while_saving_the_VAT_rate'));
+ growl.error(_t('app.admin.invoices.an_error_occurred_while_saving_the_VAT_rate'));
return console.error(error);
});
return Setting.update({ name: 'invoice_VAT-active' }, { value: result.active ? 'true' : 'false' }, function (data) {
$scope.invoice.VAT.active = result.active;
if (result.active) {
- return growl.success(_t('invoices.VAT_successfully_activated'));
+ return growl.success(_t('app.admin.invoices.VAT_successfully_activated'));
} else {
- return growl.success(_t('invoices.VAT_successfully_disabled'));
+ return growl.success(_t('app.admin.invoices.VAT_successfully_disabled'));
}
}
, function (error) {
- growl.error(_t('invoices.an_error_occurred_while_activating_the_VAT'));
+ growl.error(_t('app.admin.invoices.an_error_occurred_while_activating_the_VAT'));
return console.error(error);
});
});
@@ -461,10 +460,10 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
const parsed = parseHtml($scope.invoice.text.content);
return Setting.update({ name: 'invoice_text' }, { value: parsed }, function (data) {
$scope.invoice.text.content = parsed;
- return growl.success(_t('invoices.text_successfully_saved'));
+ return growl.success(_t('app.admin.invoices.text_successfully_saved'));
}
, function (error) {
- growl.error(_t('invoices.an_error_occurred_while_saving_the_text'));
+ growl.error(_t('app.admin.invoices.an_error_occurred_while_saving_the_text'));
return console.error(error);
});
};
@@ -476,10 +475,10 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
const parsed = parseHtml($scope.invoice.legals.content);
return Setting.update({ name: 'invoice_legals' }, { value: parsed }, function (data) {
$scope.invoice.legals.content = parsed;
- return growl.success(_t('invoices.address_and_legal_information_successfully_saved'));
+ return growl.success(_t('app.admin.invoices.address_and_legal_information_successfully_saved'));
}
, function (error) {
- growl.error(_t('invoices.an_error_occurred_while_saving_the_address_and_the_legal_information'));
+ growl.error(_t('app.admin.invoices.an_error_occurred_while_saving_the_address_and_the_legal_information'));
return console.error(error);
});
};
@@ -552,7 +551,7 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
$scope.save = function() {
Setting.bulkUpdate(
{ settings: Object.values($scope.settings) },
- function () { growl.success(_t('invoices.codes_customization_success')); },
+ function () { growl.success(_t('app.admin.invoices.codes_customization_success')); },
function (error) {
growl.error('unexpected_error_occurred');
console.error(error);
@@ -560,6 +559,126 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
);
}
+ /**
+ * Setup the feature-tour for the admin/invoices page.
+ * This is intended as a contextual help (when pressing F1)
+ */
+ $scope.setupInvoicesTour = function () {
+ // get the tour defined by the ui-tour directive
+ const uitour = uiTourService.getTourByName('invoices');
+ uitour.createStep({
+ selector: 'body',
+ stepId: 'welcome',
+ order: 0,
+ title: _t('app.admin.tour.invoices.welcome.title'),
+ content: _t('app.admin.tour.invoices.welcome.content'),
+ placement: 'bottom',
+ orphan: true
+ });
+ if (!Fablab.withoutInvoices && $scope.invoices.length > 0) {
+ uitour.createStep({
+ selector: '.invoices-management .invoices-list',
+ stepId: 'list',
+ order: 1,
+ title: _t('app.admin.tour.invoices.list.title'),
+ content: _t('app.admin.tour.invoices.list.content'),
+ placement: 'top'
+ });
+ uitour.createStep({
+ selector: '.invoices-management .invoices-list .chained-indicator',
+ stepId: 'chained',
+ order: 2,
+ title: _t('app.admin.tour.invoices.chained.title'),
+ content: _t('app.admin.tour.invoices.chained.content'),
+ placement: 'right'
+ });
+ uitour.createStep({
+ selector: '.invoices-management .invoices-list .download-button',
+ stepId: 'download',
+ order: 3,
+ title: _t('app.admin.tour.invoices.download.title'),
+ content: _t('app.admin.tour.invoices.download.content'),
+ placement: 'left'
+ });
+ uitour.createStep({
+ selector: '.invoices-management .invoices-list .refund-button',
+ stepId: 'refund',
+ order: 4,
+ title: _t('app.admin.tour.invoices.refund.title'),
+ content: _t('app.admin.tour.invoices.refund.content'),
+ placement: 'left'
+ });
+ }
+ uitour.createStep({
+ selector: '.invoices-management .invoices-settings',
+ stepId: 'settings',
+ order: 5,
+ title: _t('app.admin.tour.invoices.settings.title'),
+ content: _t('app.admin.tour.invoices.settings.content'),
+ placement: 'bottom'
+ });
+ uitour.createStep({
+ selector: '.invoices-management .accounting-codes-tab',
+ stepId: 'codes',
+ order: 6,
+ title: _t('app.admin.tour.invoices.codes.title'),
+ content: _t('app.admin.tour.invoices.codes.content'),
+ placement: 'bottom'
+ });
+ uitour.createStep({
+ selector: '.heading .export-accounting-button',
+ stepId: 'export',
+ order: 7,
+ title: _t('app.admin.tour.invoices.export.title'),
+ content: _t('app.admin.tour.invoices.export.content'),
+ placement: 'bottom'
+ });
+ uitour.createStep({
+ selector: '.heading .close-accounting-periods-button',
+ stepId: 'periods',
+ order: 8,
+ title: _t('app.admin.tour.invoices.periods.title'),
+ content: _t('app.admin.tour.invoices.periods.content'),
+ placement: 'bottom',
+ popupClass: 'shift-left-50'
+ });
+ uitour.createStep({
+ selector: 'body',
+ stepId: 'conclusion',
+ order: 9,
+ title: _t('app.admin.tour.conclusion.title'),
+ content: _t('app.admin.tour.conclusion.content'),
+ placement: 'bottom',
+ orphan: true
+ });
+ // on step change, change the active tab if needed
+ uitour.on('stepChanged', function (nextStep) {
+ if (nextStep.stepId === 'list' || nextStep.stepId === 'settings') {
+ $scope.tabs.active = 0;
+ }
+ if (nextStep.stepId === 'settings') {
+ $scope.tabs.active = 1;
+ }
+ if (nextStep.stepId === 'codes' || nextStep.stepId === 'export') {
+ $scope.tabs.active = 2;
+ }
+ });
+ // on tour end, save the status in database
+ uitour.on('ended', function () {
+ if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile.tours.indexOf('invoices') < 0) {
+ Member.completeTour({ id: $scope.currentUser.id }, { tour: 'invoices' }, function (res) {
+ $scope.currentUser.profile.tours = res.tours;
+ });
+ }
+ });
+ // if the user has never seen the tour, show him now
+ if (Fablab.featureTourDisplay !== 'manual' && $scope.currentUser.profile.tours.indexOf('invoices') < 0) {
+ uitour.start();
+ }
+ // start this tour when an user press F1 - this is contextual help
+ window.addEventListener('keydown', handleF1);
+ }
+
/* PRIVATE SCOPE */
/**
@@ -586,19 +705,24 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
};
// Watch the logo, when a change occurs, save it
- return $scope.$watch('invoice.logo', function () {
+ $scope.$watch('invoice.logo', function () {
if ($scope.invoice.logo && $scope.invoice.logo.filesize) {
return Setting.update(
{ name: 'invoice_logo' },
{ value: $scope.invoice.logo.base64 },
- function (data) { growl.success(_t('invoices.logo_successfully_saved')); },
+ function (data) { growl.success(_t('app.admin.invoices.logo_successfully_saved')); },
function (error) {
- growl.error(_t('invoices.an_error_occurred_while_saving_the_logo'));
+ growl.error(_t('app.admin.invoices.an_error_occurred_while_saving_the_logo'));
return console.error(error);
}
);
}
});
+
+ // listen the $destroy event of the controller to remove the F1 key binding
+ $scope.$on('$destroy', function () {
+ window.removeEventListener('keydown', handleF1);
+ });
};
/**
@@ -666,6 +790,18 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
});
};
+ /**
+ * Callback used to trigger the feature tour when the user press the F1 key.
+ * @param e {KeyboardEvent}
+ */
+ const handleF1 = function (e) {
+ if (e.key === 'F1') {
+ e.preventDefault();
+ const tour = uiTourService.getTourByName('invoices');
+ if (tour) { tour.start(); }
+ }
+ };
+
// !!! MUST BE CALLED AT THE END of the controller
return initialize();
}
@@ -696,17 +832,17 @@ Application.Controllers.controller('AvoirModalController', ['$scope', '$uibModal
// Possible refunding methods
$scope.avoirModes = [
- { name: _t('invoices.none'), value: 'none' },
- { name: _t('invoices.by_cash'), value: 'cash' },
- { name: _t('invoices.by_cheque'), value: 'cheque' },
- { name: _t('invoices.by_transfer'), value: 'transfer' },
- { name: _t('invoices.by_wallet'), value: 'wallet' }
+ { name: _t('app.admin.invoices.none'), value: 'none' },
+ { name: _t('app.admin.invoices.by_cash'), value: 'cash' },
+ { name: _t('app.admin.invoices.by_cheque'), value: 'cheque' },
+ { name: _t('app.admin.invoices.by_transfer'), value: 'transfer' },
+ { name: _t('app.admin.invoices.by_wallet'), value: 'wallet' }
];
// If a subscription was took with the current invoice, should it be canceled or not
$scope.subscriptionExpireOptions = {};
- $scope.subscriptionExpireOptions[_t('yes')] = true;
- $scope.subscriptionExpireOptions[_t('no')] = false;
+ $scope.subscriptionExpireOptions[_t('app.shared.buttons.yes')] = true;
+ $scope.subscriptionExpireOptions[_t('app.shared.buttons.no')] = false;
// AngularUI-Bootstrap datepicker parameters to define when to refund
$scope.datePicker = {
@@ -742,7 +878,7 @@ Application.Controllers.controller('AvoirModalController', ['$scope', '$uibModal
}
if ($scope.avoir.invoice_items_ids.length === 0) {
- return growl.error(_t('invoices.you_must_select_at_least_one_element_to_create_a_refund'));
+ return growl.error(_t('app.admin.invoices.you_must_select_at_least_one_element_to_create_a_refund'));
} else {
return Invoice.save(
{ avoir: $scope.avoir },
@@ -750,7 +886,7 @@ Application.Controllers.controller('AvoirModalController', ['$scope', '$uibModal
$uibModalInstance.close({ avoir, invoice: $scope.invoice });
},
function (err) { // failed
- growl.error(_t('invoices.unable_to_create_the_refund'));
+ growl.error(_t('app.admin.invoices.unable_to_create_the_refund'));
}
);
}
@@ -791,7 +927,7 @@ Application.Controllers.controller('AvoirModalController', ['$scope', '$uibModal
});
if (invoice.stripe) {
- return $scope.avoirModes.push({ name: _t('invoices.online_payment'), value: 'stripe' });
+ return $scope.avoirModes.push({ name: _t('app.admin.invoices.online_payment'), value: 'stripe' });
}
};
@@ -861,16 +997,16 @@ Application.Controllers.controller('ClosePeriodModalController', ['$scope', '$ui
resolve: {
object () {
return {
- title: _t('invoices.confirmation_required'),
+ title: _t('app.admin.invoices.confirmation_required'),
msg: $sce.trustAsHtml(
_t(
'invoices.confirm_close_START_END',
{ START: moment.utc($scope.period.start_at).format('LL'), END: moment.utc($scope.period.end_at).format('LL') }
)
+ '