Merge branch 'develop' into feature/test_table_design

This commit is contained in:
Yuriy Kulakov 2021-04-25 20:34:44 +03:00
commit 7e3b2a918e
23 changed files with 1223 additions and 215 deletions

View File

@ -1,145 +1,18 @@
import logging
import os
from datetime import timedelta, datetime, date
from typing import Optional
from django.contrib.auth.models import User
from django.core.exceptions import ObjectDoesNotExist
from django.shortcuts import redirect
from django.utils import timezone
from zenpy import Zenpy
from zenpy.lib.exception import APIException
from zenpy.lib.api_objects import User as ZenpyUser, Ticket as ZenpyTicket
from access_controller.settings import ZENDESK_ROLES as ROLES, ONE_DAY, ZENDESK_GROUPS, SOLVED_TICKETS_EMAIL, \
ACTRL_API_EMAIL, ACTRL_API_TOKEN, ACTRL_API_PASSWORD, ACTRL_ZENDESK_SUBDOMAIN
from access_controller.settings import ZENDESK_ROLES as ROLES, ONE_DAY, ACTRL_ZENDESK_SUBDOMAIN
from main.models import UserProfile, RoleChangeLogs, UnassignedTicket, UnassignedTicketStatus
class ZendeskAdmin:
"""
Класс **ZendeskAdmin** существует, чтобы в каждой функции отдельно не проверять аккаунт администратора.
:param credentials: Полномочия (первым указывается учетная запись организации в Zendesk)
:type credentials: :class:`dict`
:param email: Email администратора, указанный в env
:type email: :class:`str`
:param token: Токен администратора (формируется в Zendesk, указывается в env)
:type token: :class:`str`
:param password: Пароль администратора, указанный в env
:type password: :class:`str`
"""
credentials: dict = {
'subdomain': ACTRL_ZENDESK_SUBDOMAIN
}
email: str = ACTRL_API_EMAIL
token: str = ACTRL_API_TOKEN
password: str = ACTRL_API_PASSWORD
def __init__(self):
self.create_admin()
def check_user(self, email: str) -> bool:
"""
Функция осуществляет проверку существования пользователя в Zendesk по email.
:param email: Email пользователя
:return: Является ли зарегистрированным
"""
return True if self.admin.search(email, type='user') else False
def get_user_name(self, email: str) -> str:
"""
Функция **get_user_name** возвращает имя пользователя по его email
"""
user = self.admin.users.search(email).values[0]
return user.name
def get_user_role(self, email: str) -> str:
"""
Функция возвращает роль пользователя по его email.
:param email: Email пользователя
:return: Роль пользователя
"""
user = self.admin.users.search(email).values[0]
return user.role
def get_user_id(self, email: str) -> str:
"""
Функция возвращает id пользователя по его email
:param email: Email пользователя
:return: ID пользователя
"""
user = self.admin.users.search(email).values[0]
return user.id
def get_user_image(self, email: str) -> str:
"""
Функция возвращает url-ссылку на аватар пользователя по его email.
:param email: Email пользователя
:return: Аватар пользователя
"""
user = self.admin.users.search(email).values[0]
return user.photo['content_url'] if user.photo else None
def get_user(self, email: str):
"""
Функция возвращает пользователя (объект) по его email.
:param email: Email пользователя
:return: Объект пользователя, найденного в БД
"""
return self.admin.users.search(email).values[0]
def get_group(self, name: str) -> str:
"""
Функция возвращает группу по названию
:param name: Имя пользователя
:return: Группы пользователя (в случае отсутствия None)
"""
groups = self.admin.search(name, type='group')
for group in groups:
return group
return None
def get_user_org(self, email: str) -> str:
"""
Функция возвращает организацию, к которой относится пользователь по его email.
:param email: Email пользователя
:return: Организация пользователя
"""
user = self.admin.users.search(email).values[0]
return user.organization.name if user.organization else None
def create_admin(self) -> None:
"""
Функция создает администратора, проверяя наличие вводимых данных в env.
:param credentials: В список полномочий администратора вносятся email, token, password из env
:type credentials: :class:`dict`
:raise: :class:`ValueError`: исключение, вызываемое если email не введен в env
:raise: :class:`APIException`: исключение, вызываемое если пользователя с таким email не существует в Zendesk
"""
if self.email is None:
raise ValueError('access_controller email not in env')
self.credentials['email'] = self.email
if self.token:
self.credentials['token'] = self.token
elif self.password:
self.credentials['password'] = self.password
else:
raise ValueError('access_controller token or password not in env')
self.admin = Zenpy(**self.credentials)
try:
self.admin.search(self.email, type='user')
except APIException:
raise ValueError('invalid access_controller`s login data')
from main.zendesk_admin import zenpy
def update_role(user_profile: UserProfile, role: int) -> None:
@ -150,7 +23,7 @@ def update_role(user_profile: UserProfile, role: int) -> None:
:param role: Новая роль
:return: Пользователь с обновленной ролью
"""
zendesk = ZendeskAdmin()
zendesk = zenpy
user = zendesk.get_user(user_profile.user.email)
user.custom_role_id = role
user_profile.custom_role_id = role
@ -176,6 +49,7 @@ def make_light_agent(user_profile: UserProfile, who_changes: User) -> None:
:return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "light_agent"
"""
tickets = get_tickets_list(user_profile.user.email)
ticket: ZenpyTicket
for ticket in tickets:
UnassignedTicket.objects.create(
assignee=user_profile.user,
@ -183,19 +57,29 @@ def make_light_agent(user_profile: UserProfile, who_changes: User) -> None:
status=UnassignedTicketStatus.SOLVED if ticket.status == 'solved' else UnassignedTicketStatus.UNASSIGNED
)
if ticket.status == 'solved':
ticket.assignee = ZendeskAdmin().get_user(SOLVED_TICKETS_EMAIL)
ticket.assignee_id = zenpy.solved_tickets_user_id
else:
ticket.assignee = None
ticket.group = ZendeskAdmin().get_group(ZENDESK_GROUPS['buffer'])
ZendeskAdmin().admin.tickets.update(ticket)
ticket.group_id = zenpy.buffer_group_id
zenpy.admin.tickets.update(tickets.values)
attempts, success = 5, False
while not success and attempts != 0:
try:
update_role(user_profile, ROLES['light_agent'])
success = True
except APIException as e:
attempts -= 1
if attempts == 0:
raise e
def get_users_list() -> list:
"""
Функция **get_users_list** возвращает список пользователей Zendesk, относящихся к организации SYSTEM.
"""
zendesk = ZendeskAdmin()
zendesk = zenpy
# У пользователей должна быть организация SYSTEM
org = next(zendesk.admin.search(type='organization', name='SYSTEM'))
@ -207,17 +91,17 @@ def get_tickets_list(email):
"""
Функция возвращает список тикетов пользователя Zendesk
"""
return ZendeskAdmin().admin.search(assignee=email, type='ticket')
return zenpy.admin.search(assignee=email, type='ticket')
def update_profile(user_profile: UserProfile) -> UserProfile:
def update_profile(user_profile: UserProfile):
"""
Функция обновляет профиль пользователя в соответствии с текущим в Zendesk.
:param user_profile: Профиль пользователя
:return: Обновленный, в соответствие с текущими данными в Zendesk, профиль пользователя
"""
user = ZendeskAdmin().get_user(user_profile.user.email)
user = zenpy.get_user(user_profile.user.email)
user_profile.name = user.name
user_profile.role = user.role
user_profile.custom_role_id = user.custom_role_id if user.custom_role_id else 0
@ -232,7 +116,7 @@ def check_user_exist(email: str) -> bool:
:param email: Email пользователя
:return: Зарегистрирован ли пользователь в Zendesk
"""
return ZendeskAdmin().check_user(email)
return zenpy.check_user(email)
def get_user_organization(email: str) -> str:
@ -242,7 +126,7 @@ def get_user_organization(email: str) -> str:
:param email: Email пользователя
:return: Организация пользователя
"""
return ZendeskAdmin().get_user_org(email)
return zenpy.get_user_org(email)
def check_user_auth(email: str, password: str) -> bool:
@ -264,7 +148,7 @@ def check_user_auth(email: str, password: str) -> bool:
return True
def update_user_in_model(profile: UserProfile, zendesk_user: User):
def update_user_in_model(profile: UserProfile, zendesk_user: ZenpyUser):
"""
Функция обновляет профиль пользователя при изменении данных пользователя на Zendesk.
@ -436,7 +320,7 @@ class StatisticData:
self.display = display_format
return True
def get_data(self) -> list:
def get_data(self) -> Optional[dict]:
"""
Функция возвращает данные - список объектов RoleChangeLogs.
"""
@ -666,3 +550,13 @@ def log(user, admin=0):
logger.addHandler(csvhandler)
logger.setLevel('INFO')
logger.info(users)
def set_session_params_for_work_page(request, count=None, is_confirm=True):
"""
Функция для страницы получения прав
Устанавливает данные сессии о успешности запроса и количестве назначенных тикетов
"""
request.session['is_confirm'] = is_confirm
request.session['count_tickets'] = count
return redirect('work', request.user.id)

View File

@ -1,14 +0,0 @@
<div class="mt-5">
{% for message in messages %}
<div
class="alert alert-{{ message.tags }} alert-dismissible fade show p-2"
role="alert"
style="display: flex; align-items: center; justify-content: space-between;"
>
{{ message }}
<div>
<button type="button" class="btn btn-light p-2" data-bs-dismiss="alert" aria-label="Close">X</button>
</div>
</div>
{% endfor %}
</div>

View File

@ -7,8 +7,8 @@
{% block heading %}Управление{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'main/css/work.css' %}"/>
<link rel="stylesheet" href="{% static 'main/css/work.css' %}" xmlns="http://www.w3.org/1999/html">
<link rel="stylesheet" href="{% static 'modules/notifications/dist/notifications.css' %}">
{% endblock %}
{% block extra_scripts %}
@ -16,8 +16,11 @@
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js" crossorigin></script>
<script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
{% endblock%}
<script src="{% static 'modules/notifications/dist/notifications.js' %}"></script>
<script src="{% static 'main/js/control.js'%}" type="text/babel"></script>
<script src="{% static 'main/js/notifications.js' %}"></script>
{% endblock%}
{% block content %}
<div class="container-md">
@ -25,6 +28,10 @@
<p class="row page-description" id="licences_remaining">Свободных Мест:</p>
</div>
{% for message in messages %}
<script>create_notification('{{message}}','','{{message.tags}}',5000)</script>
{% endfor %}
{% block form %}
<form method="post">
{% csrf_token %}
@ -100,9 +107,6 @@
</form>
{% endblock %}
{% include 'base/success_messages.html' %}
</div>
<script src="{% static 'main/js/control.js'%}" type="text/babel"></script>
{% endblock %}

View File

@ -7,7 +7,12 @@
{% block heading %}Управление правами{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'main/css/work.css' %}">
<link rel="stylesheet" href="{% static 'main/css/work.css' %}" xmlns="http://www.w3.org/1999/html">
<link rel="stylesheet" href="{% static 'modules/notifications/dist/notifications.css' %}">
{% endblock %}
{% block extra_scripts %}
<script src="{% static 'modules/notifications/dist/notifications.js' %}"></script>
<script src="{% static 'main/js/notifications.js' %}"></script>
{% endblock %}
{% block content %}
@ -66,8 +71,10 @@
<button type="submit" class="default-button">Взять тикеты в работу</button>
</form>
</div>
{% for message in messages %}
<script>create_notification('{{message}}','','{{message.tags}}',5000)</script>
{% endfor %}
</div>
{% include 'base/success_messages.html' %}
</div>
{% endblock %}

View File

@ -1,4 +1,5 @@
from smtplib import SMTPException
from typing import Dict, Any
from django.contrib import messages
from django.contrib.auth.decorators import login_required
@ -11,28 +12,26 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.messages.views import SuccessMessageMixin
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponseRedirect, HttpResponse
from django.shortcuts import render, redirect, get_list_or_404
from django.shortcuts import render, redirect
from django.urls import reverse_lazy, reverse
from django.views.generic import FormView
from django_registration.views import RegistrationView
# Django REST
from rest_framework import viewsets
from rest_framework.response import Response
from zenpy.lib.api_objects import User as ZenpyUser
from access_controller.settings import DEFAULT_FROM_EMAIL, ZENDESK_ROLES, ZENDESK_MAX_AGENTS
from main.extra_func import check_user_exist, update_profile, get_user_organization, \
make_engineer, make_light_agent, get_users_list, update_users_in_model, count_users, \
StatisticData, log, ZendeskAdmin
StatisticData, log, set_session_params_for_work_page
from main.zendesk_admin import zenpy
from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm, StatisticForm
from main.serializers import ProfileSerializer, ZendeskUserSerializer
from .models import UserProfile
def setup_context(profile_lit: bool = False, control_lit: bool = False, work_lit: bool = False,
registration_lit: bool = False, login_lit: bool = False, stats_lit: bool = False):
print(profile_lit, control_lit, work_lit, registration_lit, login_lit)
registration_lit: bool = False, login_lit: bool = False, stats_lit: bool = False) -> Dict[str, Any]:
context = {
'profile_lit': profile_lit,
@ -159,18 +158,6 @@ def profile_page(request: WSGIRequest) -> HttpResponse:
return render(request, 'pages/profile.html', context)
def auth_user(request: WSGIRequest) -> ZenpyUser:
"""
Функция возвращает профиль пользователя на Zendesk.
:param request: email, subdomain и token пользователя
:return: объект пользователя Zendesk
"""
admin = ZendeskAdmin().admin
zenpy_user: ZenpyUser = admin.users.search(request.user.email).values[0]
return zenpy_user, admin
@login_required()
def work_page(request: WSGIRequest, id: int) -> HttpResponse:
"""
@ -182,64 +169,75 @@ def work_page(request: WSGIRequest, id: int) -> HttpResponse:
"""
users = get_users_list()
if request.user.id == id:
if request.session.get('is_confirm', None):
messages.success(request, 'Изменения были применены')
elif request.session.get('is_confirm', None) is not None:
messages.error(request, 'Изменения не были применены')
count = request.session.get('count_tickets', None)
if count is not None:
messages.success(request, f'{count} тикетов назначено')
request.session['is_confirm'] = None
request.session['count_tickets'] = None
engineers = []
light_agents = []
for user in users:
if user.custom_role_id == ZENDESK_ROLES['engineer']:
engineers.append(user)
elif user.custom_role_id == ZENDESK_ROLES['light_agent']:
light_agents.append(user)
context = setup_context(work_lit=True)
context.update({
'engineers': engineers,
'agents': light_agents,
'messages': messages.get_messages(request),
'licences_remaining': max(0, ZENDESK_MAX_AGENTS - len(engineers)),
'pagename': 'Управление правами'
'pagename': 'Управление правами',
})
return render(request, 'pages/work.html', context)
return redirect("login")
@login_required()
def work_hand_over(request: WSGIRequest) -> HttpResponseRedirect:
def work_hand_over(request: WSGIRequest):
"""
Функция позволяет текущему пользователю (login_required) сдать права, а именно сменить в Zendesk роль с "engineer" на "light agent"
и установить роль "agent" в БД. Действия выполняются, если исходная роль пользователя "engineer".
Функция позволяет текущему пользователю сдать права, а именно сменить в Zendesk роль с "engineer" на "light_agent"
:param request: данные текущего пользователя (login_required)
:return: перезагрузка текущей страницы после выполнения смены роли
"""
make_light_agent(request.user.userprofile,request.user)
return HttpResponseRedirect(reverse('work', args=(request.user.id,)))
make_light_agent(request.user.userprofile, request.user)
return set_session_params_for_work_page(request)
@login_required()
def work_become_engineer(request: WSGIRequest) -> HttpResponseRedirect:
"""
Функция меняет роль пользователя в Zendesk на "engineer" и присваивает роль "agent" в БД (в случае, если исходная роль пользователя была "light_agent").
Функция позволяет текущему пользователю получить права, а именно сменить в Zendesk роль с "light_agent" на "engineer"
:param request: данные текущего пользователя (login_required)
:return: перезагрузка текущей страницы после выполнения смены роли
"""
zenpy_user, admin = auth_user(request)
make_engineer(request.user.userprofile,request.user)
return HttpResponseRedirect(reverse('work', args=(request.user.id,)))
make_engineer(request.user.userprofile, request.user)
return set_session_params_for_work_page(request)
@login_required()
def work_get_tickets(request):
zenpy_user, admin = auth_user(request)
count_tickets = int(request.GET["count_tickets"])
tickets = [ticket for ticket in admin.search(type="ticket") if ticket.group.name == 'Сменная группа' and ticket.assignee is None]
zenpy_user = zenpy.get_user(request.user.email)
if zenpy_user.role == 'admin' or zenpy_user.custom_role_id == ZENDESK_ROLES['engineer']:
tickets = [ticket for ticket in zenpy.admin.search(type="ticket") if
ticket.group.name == 'Сменная группа' and ticket.assignee is None]
count = 0
for i in range(len(tickets)):
if i == count_tickets:
return HttpResponseRedirect(reverse('work', args=(request.user.id,)))
if i == int(request.GET.get('count_tickets')):
return set_session_params_for_work_page(request, count)
tickets[i].assignee = zenpy_user
admin.tickets.update(tickets[i])
return HttpResponseRedirect(reverse('work', args=(request.user.id,)))
zenpy.admin.tickets.update(tickets[i])
count += 1
return set_session_params_for_work_page(request, count)
return set_session_params_for_work_page(request, is_confirm=False)
def main_page(request: WSGIRequest) -> HttpResponse:
@ -377,16 +375,16 @@ def statistic_page(request: WSGIRequest) -> HttpResponse:
if form.is_valid():
start_date, end_date = form.cleaned_data['range_start'], form.cleaned_data['range_end']
interval, show = form.cleaned_data['interval'], form.cleaned_data['display_format']
Data = StatisticData(start_date, end_date, form.cleaned_data['email'])
Data.set_display(show)
Data.set_interval(interval)
stats = Data.get_statistic()
if Data.errors:
context['errors'] = Data.errors
if Data.warnings:
context['warnings'] = Data.warnings
data = StatisticData(start_date, end_date, form.cleaned_data['email'])
data.set_display(show)
data.set_interval(interval)
stats = data.get_statistic()
if data.errors:
context['errors'] = data.errors
if data.warnings:
context['warnings'] = data.warnings
context['log_stats'] = stats if not context['errors'] else None
if request.method == 'GET':
elif request.method == 'GET':
form = StatisticForm()
context['form'] = form
return render(request, 'pages/statistic.html', context)

93
main/zendesk_admin.py Normal file
View File

@ -0,0 +1,93 @@
from typing import Optional, Dict
from zenpy import Zenpy
from zenpy.lib.api_objects import User as ZenpyUser, Group as ZenpyGroup
from zenpy.lib.exception import APIException
from access_controller.settings import ACTRL_ZENDESK_SUBDOMAIN, ACTRL_API_EMAIL, ACTRL_API_TOKEN, ACTRL_API_PASSWORD, \
ZENDESK_GROUPS, SOLVED_TICKETS_EMAIL
class ZendeskAdmin:
"""
Класс **ZendeskAdmin** существует, чтобы в каждой функции отдельно не проверять аккаунт администратора.
:param credentials: Полномочия (первым указывается учетная запись организации в Zendesk)
:type credentials: :class:`Dict[str, str]`
"""
def __init__(self, credentials: Dict[str, str]):
self.credentials = credentials
self.admin = self.create_admin()
self.buffer_group_id: int = self.get_group(ZENDESK_GROUPS['buffer']).id
self.solved_tickets_user_id: int = self.get_user(SOLVED_TICKETS_EMAIL).id
def check_user(self, email: str) -> bool:
"""
Функция осуществляет проверку существования пользователя в Zendesk по email.
:param email: Email пользователя
:return: Является ли зарегистрированным
"""
return True if self.admin.search(email, type='user') else False
def get_user(self, email: str) -> ZenpyUser:
"""
Функция возвращает пользователя (объект) по его email.
:param email: Email пользователя
:return: Объект пользователя, найденного в БД
"""
return self.admin.users.search(email).values[0]
def get_group(self, name: str) -> Optional[ZenpyGroup]:
"""
Функция возвращает группу по названию
:param name: Имя пользователя
:return: Группы пользователя (в случае отсутствия None)
"""
groups = self.admin.search(name, type='group')
for group in groups:
return group
return None
def get_user_org(self, email: str) -> str:
"""
Функция возвращает организацию, к которой относится пользователь по его email.
:param email: Email пользователя
:return: Организация пользователя
"""
user = self.admin.users.search(email).values[0]
return user.organization.name if user.organization else None
def create_admin(self) -> Zenpy:
"""
Функция создает администратора, проверяя наличие вводимых данных в env.
:raise: :class:`ValueError`: исключение, вызываемое если email не введен в env
:raise: :class:`APIException`: исключение, вызываемое если пользователя с таким email не существует в Zendesk
"""
if self.credentials.get('email') is None:
raise ValueError('access_controller email not in env')
if self.credentials.get('token') is None and self.credentials.get('password') is None:
raise ValueError('access_controller token or password not in env')
admin = Zenpy(**self.credentials)
try:
admin.search(self.credentials['email'], type='user')
except APIException:
raise ValueError('invalid access_controller`s login data')
return admin
zenpy = ZendeskAdmin({
'subdomain': ACTRL_ZENDESK_SUBDOMAIN,
'email': ACTRL_API_EMAIL,
'token': ACTRL_API_TOKEN,
'password': ACTRL_API_PASSWORD,
})

View File

@ -0,0 +1,14 @@
"use strict";
function create_notification(title,description,theme,time){
const myNotification = window.createNotification({
closeOnClick: true,
displayCloseButton: true,
positionClass: 'nfc-top-right',
theme: theme,
showDuration: Number(time),
});
myNotification({
title: title,
message: description
});
};

View File

@ -0,0 +1,3 @@
{
"presets": ["es2015"]
}

View File

@ -0,0 +1,31 @@
module.exports = {
"env": {
"browser": true,
"commonjs": true,
"es6": true
},
"extends": "eslint:recommended",
"parserOptions": {
"sourceType": "module"
},
"rules": {
"indent": [
"error",
"tab"
],
"linebreak-style": [
"error",
"windows"
],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"always"
],
"no-console": 0,
"no-undef": 0
}
};

30
static/modules/notifications/.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# IDE files
.idea/
.DS_Store
# Build directories
build/
# Dependency directories
node_modules/
jspm_packages/
# Lock files
yarn.lock
package-lock.json
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Yarn Integrity file
.yarn-integrity

View File

@ -0,0 +1,3 @@
language: node_js
node_js:
- "7"

View File

@ -0,0 +1,7 @@
# Notifications license
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,104 @@
const { partial, append, isString, createElement, createParagraph } = require('../src/helpers');
const addNumbers = (x, y) => x + y;
const sum = (...numbers) => numbers.reduce((total, current) => total + current, 0);
describe('Helpers', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
describe('Partial', () => {
it('should return a partially applied function', () => {
expect(typeof partial(addNumbers, 10)).toEqual('function');
});
it('should execute function when partially applied function is called', () => {
expect(partial(addNumbers, 20)(10)).toEqual(30);
});
it('should gather argument', () => {
expect(partial(sum, 5, 10)(15, 20, 25)).toEqual(75);
});
});
describe('Append', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const elementToAppend = document.createElement('h1');
elementToAppend.classList.add('heading');
elementToAppend.innerText = 'working';
append(container, elementToAppend);
const element = document.querySelector('.heading');
expect(element);
expect(element.innerText).toEqual('working');
});
describe('Is string', () => {
expect(isString(1)).toEqual(false);
expect(isString(null)).toEqual(false);
expect(isString(undefined)).toEqual(false);
expect(isString({})).toEqual(false);
expect(isString('')).toEqual(true);
expect(isString('a')).toEqual(true);
expect(isString('1')).toEqual(true);
expect(isString('some string')).toEqual(true);
});
describe('Create element', () => {
it('should create an element', () => {
expect(createElement('p')).toEqual(document.createElement('p'));
expect(createElement('h1')).toEqual(document.createElement('h1'));
expect(createElement('ul')).toEqual(document.createElement('ul'));
expect(createElement('li')).toEqual(document.createElement('li'));
expect(createElement('div')).toEqual(document.createElement('div'));
expect(createElement('span')).toEqual(document.createElement('span'));
});
it('should add class names', () => {
expect(createElement('div', 'someclass1', 'someclass2').classList.contains('someclass2'));
expect(createElement('p', 'para', 'test').classList.contains('para'));
const mockUl = document.createElement('ul');
mockUl.classList.add('nav');
mockUl.classList.add('foo');
expect(createElement('ul', 'nav', 'foo').classList).toEqual(mockUl.classList);
});
});
describe('Create paragraph', () => {
it('should create a paragraph', () => {
const p = document.createElement('p');
p.innerText = 'Some text';
expect(createParagraph()('Some text')).toEqual(p);
});
it('should add class names', () => {
const p = document.createElement('p');
p.classList.add('body-text');
p.classList.add('para');
expect(createParagraph('body-text', 'para')('')).toEqual(p);
});
it('should set inner text', () => {
const p = document.createElement('p');
p.innerText = 'Hello world!';
p.classList.add('text');
expect(createParagraph('text')('Hello world!')).toEqual(p);
});
it('should append to DOM', () => {
append(document.body, createParagraph('text')('hello'));
expect(document.querySelector('.text').innerText).toEqual('hello');
});
});
});

View File

@ -0,0 +1,144 @@
require('../src/index');
describe('Notifications', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
it('should display a console warning if no title or message is passed', () => {
jest.spyOn(global.console, 'warn');
window.createNotification()();
expect(console.warn).toBeCalled();
});
it('should render a default notification', () => {
const notification = window.createNotification();
const title = 'I am a title';
// Should initially not contain any notifications
expect(document.querySelectorAll('.ncf').length).toEqual(0);
// Create a notification instance with a title
notification({ title });
// Should be one notification with the title passed in
expect(document.querySelectorAll('.ncf').length).toEqual(1);
expect(document.querySelector('.ncf-title').innerText).toEqual(title);
// Create a second instance so there should now be two instances
notification({ title });
expect(document.querySelectorAll('.ncf').length).toEqual(2);
});
it('should close on click if the option is enabled', () => {
const notification = window.createNotification({
closeOnClick: true
});
// Create a notification with a generic body
notification({ message: 'some text' });
// Should be one notification instance
expect(document.querySelectorAll('.ncf').length).toEqual(1);
// Click the notification
document.querySelector('.ncf').click();
expect(document.querySelectorAll('.ncf').length).toEqual(0);
});
it('should not close on click if the option is disabled', () => {
const notification = window.createNotification({
closeOnClick: false
});
// Create a notification with a generic body
notification({ message: 'some text' });
// Should be one notification instance
expect(document.querySelectorAll('.ncf').length).toEqual(1);
// Click the notification
document.querySelector('.ncf').click();
expect(document.querySelectorAll('.ncf').length).toEqual(1);
});
it('should set position class if valid', () => {
const validPositions = [
'nfc-top-left',
'nfc-top-right',
'nfc-bottom-left',
'nfc-bottom-right'
];
validPositions.forEach(position => {
const notification = window.createNotification({
positionClass: position
});
notification({ title: 'title here' });
const className = `.${position}`;
expect(document.querySelectorAll(className).length).toEqual(1);
const container = document.querySelector(className);
expect(container.querySelectorAll('.ncf').length).toEqual(1);
});
});
it('should revert to default to default position and warn if class is invalid', () => {
const notification = window.createNotification({
positionClass: 'invalid-name'
});
jest.spyOn(global.console, 'warn');
notification({ message: 'test' });
expect(console.warn).toBeCalled();
expect(document.querySelectorAll('.nfc-top-right').length).toEqual(1);
});
it('should allow a custom onclick callback', () => {
let a = 'not clicked';
const notification = window.createNotification({
onclick: () => {
a = 'clicked';
}
});
notification({ message: 'click test' });
expect(a).toEqual('not clicked');
// Click the notification
document.querySelector('.ncf').click();
expect(a).toEqual('clicked');
});
it('should show for correct duration', () => {
const notification = window.createNotification({
showDuration: 500
});
notification({ message: 'test' });
expect(document.querySelectorAll('.ncf').length).toEqual(1);
// Should exist after 400ms
setTimeout(() => {
expect(document.querySelectorAll('.ncf').length).toEqual(1);
}, 400);
// Should delete after 500ms
setTimeout(() => {
expect(document.querySelectorAll('.ncf').length).toEqual(0);
});
}, 501);
});

View File

@ -0,0 +1,34 @@
'use strict';
// Written using ES5 JS for browser support
window.addEventListener('DOMContentLoaded', function () {
var form = document.querySelector('form');
form.addEventListener('submit', function (e) {
e.preventDefault();
// Form elements
var title = form.querySelector('#title').value;
var message = form.querySelector('#message').value;
var position = form.querySelector('#position').value;
var duration = form.querySelector('#duration').value;
var theme = form.querySelector('#theme').value;
var closeOnClick = form.querySelector('#close').checked;
var displayClose = form.querySelector('#closeButton').checked;
if(!message) {
message = 'You did not enter a message...';
}
window.createNotification({
closeOnClick: closeOnClick,
displayCloseButton: displayClose,
positionClass: position,
showDuration: duration,
theme: theme
})({
title: title,
message: message
});
});
});

View File

@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en">
</hea>
<meta charset="UTF-8">
<title>Notifications</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: floralwhite;
font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
text-align: center;
padding: 30px;
}
h1 {
margin-top: 0;
}
form {
text-align: left;
max-width: 600px;
width: 100%;
margin: 0 auto;
display: block;
}
input, select {
width: 100%;
margin-top: 5px;
margin-bottom: 20px;
padding: 5px;
box-sizing: border-box;
}
input[type=checkbox] {
width: auto;
}
input[type=submit] {
background: #2f96b4;
border: 0;
color: #fff;
margin-bottom: 0;
padding: 10px 5px;
}
</style>
<!-- Notifications styling -->
<link rel="stylesheet" href="../dist/notifications.css" type="text/css">
<head>
<body>
<h1>Notifications</h1>
<form>
<label for="title">Title (optional)</label>
<br/>
<input type="text" id="title" placeholder="Enter a title..." value="Notification">
<label for="message">Message</label>
<br/>
<input type="text" id="message" placeholder="Enter a message..." value="I am a default message">
<label for="position">Notification position:</label>
<br/>
<select id="position">
<option value="nfc-top-right">Top Right</option>
<option value="nfc-bottom-right">Bottom Right</option>
<option value="nfc-top-left">Top Left</option>
<option value="nfc-bottom-left">Bottom Left</option>
</select>
<label for="duration">Show Duration (ms)</label>
<br/>
<input id="duration" type="number" value="3000"/>
<label for="theme">Theme</label>
<br/>
<select id="theme">
<option value="success">Success</option>
<option value="info">Information</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
<option value="none">None</option>
</select>
<label for="close">Close on click</label>
<input id="close" type="checkbox" value="Close on click" checked>
<br/>
<label for="closeButton">Display a close button</label>
<input id="closeButton" type="checkbox" value="Display close button">
<input type="submit" value="Display notification">
</form>
<script src="../dist/notifications.js" type="text/javascript"></script>
<script src="./demo.js" type="text/javascript"></script>
</body>
</html>

View File

@ -0,0 +1,58 @@
{
"name": "styled-notifications",
"version": "1.0.1",
"description": "A simple JavaScript notifications library",
"main": "dist/notifications.js",
"scripts": {
"start": "webpack --watch",
"build": "webpack -p",
"test": "jest",
"prepare": "yarn run test && yarn run build"
},
"pre-commit": [
"prepare"
],
"files": [
"dist"
],
"repository": {
"type": "git",
"url": "git+https://github.com/JamieLivingstone/Notifications.git"
},
"keywords": [
"notification",
"popup",
"alert",
"toast"
],
"author": "Jamie Livingstone",
"contributors": [
{
"name": "Jamie Livingstone (https://github.com/JamieLivingstone)"
},
{
"name": "cavebeavis (https://github.com/cavebeavis)"
}
],
"license": "ISC",
"bugs": {
"url": "https://github.com/JamieLivingstone/Notifications/issues"
},
"homepage": "https://github.com/JamieLivingstone/Notifications#readme",
"devDependencies": {
"babel-core": "^6.26.0",
"babel-jest": "^21.0.2",
"babel-loader": "^7.1.2",
"babel-preset-es2015": "^6.24.1",
"babel-preset-es2015-ie": "^6.7.0",
"css-loader": "^0.28.7",
"eslint": "^4.6.1",
"extract-text-webpack-plugin": "^3.0.0",
"jest": "^21.0.2",
"node-sass": "^4.5.3",
"pre-commit": "^1.2.2",
"sass-loader": "^6.0.6",
"style-loader": "^0.18.2",
"webpack": "^3.5.6"
}
}

View File

@ -0,0 +1,82 @@
[![Build Status](https://travis-ci.org/JamieLivingstone/Notifications.svg?branch=master)](https://travis-ci.org/JamieLivingstone/Notifications)
# Notifications
**Notifications** is a Javascript library for notifications heavily inspired by toastr but does not require any dependencies such as jQuery.
Works on browsers: IE9+, Safari, Chrome, FireFox, opera, edge
## npm Installation
Do either
```
npm i styled-notifications
```
or add the following to your `package.json`:
```
"dependencies": {
"styled-notifications": "^1.0.1"
},
```
## Installation
Download files from the dist folder and then:
1. Link to notifications.css `<link href="notifications.css" rel="stylesheet"/>`
2. Link to notifications.js `<script src="notifications.js"></script>`
## Usage
### Custom options
- closeOnClick <bool> - Close the notification dialog when a click is invoked.
- displayCloseButton <bool> - Display a close button in the top right hand corner of the notification.
- positionClass <string> - Set the position of the notification dialog. Accepted positions: ('nfc-top-right', 'nfc-bottom-right', 'nfc-bottom-left', 'nfc-top-left').
- onClick <function(event)> - Call a callback function when a click is invoked on a notification.
- showDuration <integer> - Milliseconds the notification should be visible (0 for a notification that will remain open until clicked)
- theme <string> - Set the position of the notification dialog. Accepted positions: ('success', 'info', 'warning', 'error', 'A custom clasName').
```
const defaultOptions = {
closeOnClick: true,
displayCloseButton: false,
positionClass: 'nfc-top-right',
onclick: false,
showDuration: 3500,
theme: 'success'
};
```
## Example
### Success notification
```
// Create a success notification instance
const successNotification = window.createNotification({
theme: 'success',
showDuration: 5000
});
// Invoke success notification
successNotification({
message: 'Simple success notification'
});
// Use the same instance but pass a title
successNotification({
title: 'Working',
message: 'Simple success notification'
});
```
### Information notification
```
// Only running it once? Invoke immediately like this
window.createNotification({
theme: 'success',
showDuration: 5000
})({
message: 'I have some information for you...'
});
```
### Todo
~~1. Add to NPM~~
2. Improve documentation
3. Further device testing
4. Add contributor instructions

View File

@ -0,0 +1,24 @@
export const partial = (fn, ...presetArgs) => (...laterArgs) => fn(...presetArgs, ...laterArgs);
export const append = (el, ...children) => children.forEach(child => el.appendChild(child));
export const isString = input => typeof input === 'string';
export const createElement = (elementType, ...classNames) => {
const element = document.createElement(elementType);
if(classNames.length) {
classNames.forEach(currentClass => element.classList.add(currentClass));
}
return element;
};
const setInnerText = (element, text) => {
element.innerText = text;
return element;
};
const createTextElement = (elementType, ...classNames) => partial(setInnerText, createElement(elementType, ...classNames));
export const createParagraph = (...classNames) => createTextElement('p', ...classNames);

View File

@ -0,0 +1,148 @@
'use strict';
// Polyfills
import './polyfills/classList';
import {
append,
createElement,
createParagraph,
isString
} from './helpers';
(function Notifications(window) {
// Default notification options
const defaultOptions = {
closeOnClick: true,
displayCloseButton: false,
positionClass: 'nfc-top-right',
onclick: false,
showDuration: 3500,
theme: 'success'
};
function configureOptions(options) {
// Create a copy of options and merge with defaults
options = Object.assign({}, defaultOptions, options);
// Validate position class
function validatePositionClass(className) {
const validPositions = [
'nfc-top-left',
'nfc-top-right',
'nfc-bottom-left',
'nfc-bottom-right'
];
return validPositions.indexOf(className) > -1;
}
// Verify position, if invalid reset to default
if (!validatePositionClass(options.positionClass)) {
console.warn('An invalid notification position class has been specified.');
options.positionClass = defaultOptions.positionClass;
}
// Verify onClick is a function
if (options.onclick && typeof options.onclick !== 'function') {
console.warn('Notification on click must be a function.');
options.onclick = defaultOptions.onclick;
}
// Verify show duration
if(typeof options.showDuration !== 'number') {
options.showDuration = defaultOptions.showDuration;
}
// Verify theme
if(!isString(options.theme) || options.theme.length === 0) {
console.warn('Notification theme must be a string with length');
options.theme = defaultOptions.theme;
}
return options;
}
// Create a new notification instance
function createNotification(options) {
// Validate options and set defaults
options = configureOptions(options);
// Return a notification function
return function notification({ title, message } = {}) {
const container = createNotificationContainer(options.positionClass);
if(!title && !message) {
return console.warn('Notification must contain a title or a message!');
}
// Create the notification wrapper
const notificationEl = createElement('div', 'ncf', options.theme);
// Close on click
if(options.closeOnClick === true) {
notificationEl.addEventListener('click', () => container.removeChild(notificationEl));
}
// Custom click callback
if(options.onclick) {
notificationEl.addEventListener('click', (e) => options.onclick(e));
}
// Display close button
if(options.displayCloseButton) {
const closeButton = createElement('button');
closeButton.innerText = 'X';
// Use the wrappers close on click to avoid useless event listeners
if(options.closeOnClick === false){
closeButton.addEventListener('click', () =>container.removeChild(notificationEl));
}
append(notificationEl, closeButton);
}
// Append title and message
isString(title) && title.length && append(notificationEl, createParagraph('ncf-title')(title));
isString(message) && message.length && append(notificationEl, createParagraph('nfc-message')(message));
// Append to container
append(container, notificationEl);
// Remove element after duration
if(options.showDuration && options.showDuration > 0) {
const timeout = setTimeout(() => {
container.removeChild(notificationEl);
// Remove container if empty
if(container.querySelectorAll('.ncf').length === 0) {
document.body.removeChild(container);
}
}, options.showDuration);
// If close on click is enabled and the user clicks, cancel timeout
if(options.closeOnClick || options.displayCloseButton) {
notificationEl.addEventListener('click', () => clearTimeout(timeout));
}
}
};
}
function createNotificationContainer(position) {
let container = document.querySelector(`.${position}`);
if(!container) {
container = createElement('div', 'ncf-container', position);
append(document.body, container);
}
return container;
}
// Add Notifications to window to make globally accessible
if (window.createNotification) {
console.warn('Window already contains a create notification function. Have you included the script twice?');
} else {
window.createNotification = createNotification;
}
})(window);

View File

@ -0,0 +1,68 @@
(function () {
if (typeof window.Element === 'undefined' || 'classList' in document.documentElement) return;
var prototype = Array.prototype,
push = prototype.push,
splice = prototype.splice,
join = prototype.join;
function DOMTokenList(el) {
this.el = el;
// The className needs to be trimmed and split on whitespace
// to retrieve a list of classes.
var classes = el.className.replace(/^\s+|\s+$/g,'').split(/\s+/);
for (var i = 0; i < classes.length; i++) {
push.call(this, classes[i]);
}
}
DOMTokenList.prototype = {
add: function(token) {
if(this.contains(token)) return;
push.call(this, token);
this.el.className = this.toString();
},
contains: function(token) {
return this.el.className.indexOf(token) != -1;
},
item: function(index) {
return this[index] || null;
},
remove: function(token) {
if (!this.contains(token)) return;
for (var i = 0; i < this.length; i++) {
if (this[i] == token) break;
}
splice.call(this, i, 1);
this.el.className = this.toString();
},
toString: function() {
return join.call(this, ' ');
},
toggle: function(token) {
if (!this.contains(token)) {
this.add(token);
} else {
this.remove(token);
}
return this.contains(token);
}
};
window.DOMTokenList = DOMTokenList;
function defineElementGetter (obj, prop, getter) {
if (Object.defineProperty) {
Object.defineProperty(obj, prop,{
get : getter
});
} else {
obj.__defineGetter__(prop, getter);
}
}
defineElementGetter(Element.prototype, 'classList', function () {
return new DOMTokenList(this);
});
})();

View File

@ -0,0 +1,134 @@
// Base colors
$success: #51A351;
$info: #2F96B4;
$warning: #f87400;
$error: #BD362F;
$grey: #999999;
.ncf-container {
font-size: 14px;
box-sizing: border-box;
position: fixed;
z-index: 999999;
&.nfc-top-left {
top: 12px;
left: 12px;
}
&.nfc-top-right {
top: 12px;
right: 12px;
}
&.nfc-bottom-right {
bottom: 12px;
right: 12px;
}
&.nfc-bottom-left {
bottom: 12px;
left: 12px;
}
@media (max-width: 767px) {
left: 0;
right: 0;
}
.ncf {
background: #ffffff;
transition: .3s ease;
position: relative;
pointer-events: auto;
overflow: hidden;
margin: 0 0 6px;
padding: 30px;
width: 300px;
border-radius: 3px 3px 3px 3px;
box-shadow: 0 0 12px $grey;
color: #000000;
opacity: 0.9;
-ms-filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=90);
filter: alpha(opacity=90);
background-position: 15px center !important;
background-repeat: no-repeat !important;
// Prevent annoying text selection
-webkit-user-select: none; /* Chrome all / Safari all */
-moz-user-select: none; /* Firefox all */
-ms-user-select: none; /* IE 10+ */
user-select: none; /* Likely future */
&:hover {
box-shadow: 0 0 12px #000000;
opacity: 1;
cursor: pointer;
}
.ncf-title {
font-weight: bold;
font-size: 16px;
text-align: left;
margin-top: 0;
margin-bottom: 6px;
word-wrap: break-word;
}
.nfc-message {
margin: 0;
text-align: left;
word-wrap: break-word;
}
}
// Themes
.success {
background: $success;
color: #ffffff;
padding: 15px 15px 15px 50px;
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADsSURBVEhLY2AYBfQMgf///3P8+/evAIgvA/FsIF+BavYDDWMBGroaSMMBiE8VC7AZDrIFaMFnii3AZTjUgsUUWUDA8OdAH6iQbQEhw4HyGsPEcKBXBIC4ARhex4G4BsjmweU1soIFaGg/WtoFZRIZdEvIMhxkCCjXIVsATV6gFGACs4Rsw0EGgIIH3QJYJgHSARQZDrWAB+jawzgs+Q2UO49D7jnRSRGoEFRILcdmEMWGI0cm0JJ2QpYA1RDvcmzJEWhABhD/pqrL0S0CWuABKgnRki9lLseS7g2AlqwHWQSKH4oKLrILpRGhEQCw2LiRUIa4lwAAAABJRU5ErkJggg==");
}
.info {
background: $info;
color: #ffffff;
padding: 15px 15px 15px 50px;
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAGwSURBVEhLtZa9SgNBEMc9sUxxRcoUKSzSWIhXpFMhhYWFhaBg4yPYiWCXZxBLERsLRS3EQkEfwCKdjWJAwSKCgoKCcudv4O5YLrt7EzgXhiU3/4+b2ckmwVjJSpKkQ6wAi4gwhT+z3wRBcEz0yjSseUTrcRyfsHsXmD0AmbHOC9Ii8VImnuXBPglHpQ5wwSVM7sNnTG7Za4JwDdCjxyAiH3nyA2mtaTJufiDZ5dCaqlItILh1NHatfN5skvjx9Z38m69CgzuXmZgVrPIGE763Jx9qKsRozWYw6xOHdER+nn2KkO+Bb+UV5CBN6WC6QtBgbRVozrahAbmm6HtUsgtPC19tFdxXZYBOfkbmFJ1VaHA1VAHjd0pp70oTZzvR+EVrx2Ygfdsq6eu55BHYR8hlcki+n+kERUFG8BrA0BwjeAv2M8WLQBtcy+SD6fNsmnB3AlBLrgTtVW1c2QN4bVWLATaIS60J2Du5y1TiJgjSBvFVZgTmwCU+dAZFoPxGEEs8nyHC9Bwe2GvEJv2WXZb0vjdyFT4Cxk3e/kIqlOGoVLwwPevpYHT+00T+hWwXDf4AJAOUqWcDhbwAAAAASUVORK5CYII=");
}
.warning {
background: $warning;
color: #ffffff;
padding: 15px 15px 15px 50px;
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAGYSURBVEhL5ZSvTsNQFMbXZGICMYGYmJhAQIJAICYQPAACiSDB8AiICQQJT4CqQEwgJvYASAQCiZiYmJhAIBATCARJy+9rTsldd8sKu1M0+dLb057v6/lbq/2rK0mS/TRNj9cWNAKPYIJII7gIxCcQ51cvqID+GIEX8ASG4B1bK5gIZFeQfoJdEXOfgX4QAQg7kH2A65yQ87lyxb27sggkAzAuFhbbg1K2kgCkB1bVwyIR9m2L7PRPIhDUIXgGtyKw575yz3lTNs6X4JXnjV+LKM/m3MydnTbtOKIjtz6VhCBq4vSm3ncdrD2lk0VgUXSVKjVDJXJzijW1RQdsU7F77He8u68koNZTz8Oz5yGa6J3H3lZ0xYgXBK2QymlWWA+RWnYhskLBv2vmE+hBMCtbA7KX5drWyRT/2JsqZ2IvfB9Y4bWDNMFbJRFmC9E74SoS0CqulwjkC0+5bpcV1CZ8NMej4pjy0U+doDQsGyo1hzVJttIjhQ7GnBtRFN1UarUlH8F3xict+HY07rEzoUGPlWcjRFRr4/gChZgc3ZL2d8oAAAAASUVORK5CYII=");
}
.error {
background: $error;
color: #ffffff;
padding: 15px 15px 15px 50px;
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAHOSURBVEhLrZa/SgNBEMZzh0WKCClSCKaIYOED+AAKeQQLG8HWztLCImBrYadgIdY+gIKNYkBFSwu7CAoqCgkkoGBI/E28PdbLZmeDLgzZzcx83/zZ2SSXC1j9fr+I1Hq93g2yxH4iwM1vkoBWAdxCmpzTxfkN2RcyZNaHFIkSo10+8kgxkXIURV5HGxTmFuc75B2RfQkpxHG8aAgaAFa0tAHqYFfQ7Iwe2yhODk8+J4C7yAoRTWI3w/4klGRgR4lO7Rpn9+gvMyWp+uxFh8+H+ARlgN1nJuJuQAYvNkEnwGFck18Er4q3egEc/oO+mhLdKgRyhdNFiacC0rlOCbhNVz4H9FnAYgDBvU3QIioZlJFLJtsoHYRDfiZoUyIxqCtRpVlANq0EU4dApjrtgezPFad5S19Wgjkc0hNVnuF4HjVA6C7QrSIbylB+oZe3aHgBsqlNqKYH48jXyJKMuAbiyVJ8KzaB3eRc0pg9VwQ4niFryI68qiOi3AbjwdsfnAtk0bCjTLJKr6mrD9g8iq/S/B81hguOMlQTnVyG40wAcjnmgsCNESDrjme7wfftP4P7SP4N3CJZdvzoNyGq2c/HWOXJGsvVg+RA/k2MC/wN6I2YA2Pt8GkAAAAASUVORK5CYII=") !important;
}
button {
position: relative;
right: -0.3em;
top: -0.3em;
float: right;
font-weight: bold;
color: #FFFFFF;
text-shadow: 0 1px 0 #ffffff;
opacity: 0.8;
line-height: 1;
font-size: 16px;
padding: 0;
cursor: pointer;
background: transparent;
border: 0;
&:hover {
opacity: 1;
}
}
}

View File

@ -0,0 +1,41 @@
const webpack = require('webpack');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const extractSass = new ExtractTextPlugin({
filename: 'notifications.css',
disable: process.env.NODE_ENV === 'development'
});
module.exports = {
entry: ['./src/index.js', './src/style.scss'],
output: {
path: __dirname + '/dist',
filename: 'notifications.js'
},
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
query: {
presets: ['babel-preset-es2015', 'es2015-ie']
}
},
{
test: /\.scss$/,
use: extractSass.extract({
use: [{
loader: 'css-loader'
}, {
loader: 'sass-loader'
}],
// use style-loader in development
fallback: 'style-loader'
})
}
],
},
plugins: [
extractSass
]
};