🚧 Templating, styling, logic and structuring

Rename /emaildomains to /domains and return a list
Minor cleanups
This commit is contained in:
nikolark committed 2020-01-03 00:17:48 +01:00
1 parent 2026da8ef2
commit 39942447ed
9 files changed
+381 -106

No files matched your search

@@ -0,0 +1,44 @@
/* Hide for mobile, show later */
.sidebar {
.orgunit-list {
display: none;
@media (min-width: 768px) {
display: block;
}
}
}
.search-field {
margin-bottom: 20px;
}
.orgunit-list {
margin-bottom: 40px;
li {
&.active {
a,
a:hover,
a:focus {
color: #fff;
background-color: $primary;
}
}
a {
padding: 0.5rem 0.8rem;
display: block;
&:hover {
text-decoration: none;
background-color: #eee;
}
}
}
}
.orgunit-select-container {
display: block;
margin-bottom: 20px;
@media (min-width: map-get($grid-breakpoints, "md")) {
display: none;
}
}
@@ -1,22 +1,238 @@
import React from 'react';
// import { parseEmails } from '../utils';
import React, { useState } from 'react';
import { groupBy, sortBy } from 'lodash';
import { useQuery } from '@apollo/react-hooks';
import ctx from 'classnames';
import { parseEmails } from '../utils';
import { GET_ALIASES, GET_DOMAINS, GET_ORG_UNITS } from '../queries';
const ListCreate = () => {
const { data, loading, error } = useQuery(GET_ORG_UNITS);
const { data: emailData, loading: emailLoading, error: emailError } = useQuery(GET_DOMAINS);
const [textareaVisible, setTextareaVisible] = useState(false);
const [listName, setListName] = useState('');
const [selectedPrefix, setSelectedPrefix] = useState(null);
if (loading || emailLoading || error || emailError) {
return null;
}
const { orgUnits } = data;
const { domains } = emailData;
const emailDomain = domains[0];
return (
<div className="new-list-container">
<button type="button" className="btn btn-default new-list-btn">
<button
type="button"
className="btn btn-outline-primary new-list-btn"
onClick={() => {
setTextareaVisible(!textareaVisible);
}}
>
<span className="fas fa-plus" aria-hidden="true" /> Opprett ny liste
</button>
<div className="new-list fwdlist" data-list-name="newlist@example.com"></div>
<div className="new-list-result"></div>
<div className={ctx('new-list fwdlist card', { visible: textareaVisible })}>
<div className="card-header">
<span className="fas fa-list-alt icon-faded" aria-hidden="true" /> <span className="">Ny liste</span>
</div>
<div className="card-body">
<div className="new-list-name input-group">
<div className="input-group-prepend">
<button
type="button"
className="btn btn btn-outline-secondary dropdown-toggle"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
>
{selectedPrefix || orgUnits[0].prefixes[0]}- <span className="caret" />
</button>
<div className="dropdown-menu prefix-select">
{orgUnits.map((ou) =>
ou.prefixes.map((prefix) => (
<a
key={`${ou.id}-${prefix}`}
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
setSelectedPrefix(prefix);
}}
>
{prefix}-
</a>
))
)}
</div>
</div>
<input
type="text"
className="form-control js-new-list-name"
value={listName}
onChange={(e) => setListName(e.target.value)}
/>
<div className="input-group-append">
<span className="input-group-text">{`@${emailDomain.name}`}</span>
</div>
</div>
<EmailsTextarea listName={listName} selectedPrefix={selectedPrefix} newList={true} />
</div>
</div>
{/* <div className="new-list-result"></div> */}
</div>
);
};
const Forwards = () => {
const AliasRow = ({ id, destination, updateDeletionList }) => {
const [checked, setChecked] = useState(false);
return (
<tr className="">
<td>{destination}</td>
<td className="del-cell">
<div className="custom-control custom-checkbox custom-control-inline">
<input
id={`alias-delete-${id}`}
type="checkbox"
className="custom-control-input"
value={checked}
onChange={(e) => {
setChecked(e.target.checked);
updateDeletionList(id, e.target.checked);
}}
/>
<label className="custom-control-label" htmlFor={`alias-delete-${id}`} />
</div>
</td>
</tr>
);
};
const ActionRow = ({ onAddEmailClick, deleteVisible, onDelete }) => {
return (
<tr className="action-row">
<td>
<a href="#" className="link-add" onClick={onAddEmailClick}>
<span className="fas fa-plus" aria-hidden="true" /> Legg til
</a>
</td>
<td>
<a href="#" className={ctx('link-del', { visible: deleteVisible })} type="button" onClick={onDelete}>
<span className="fas fa-trash" aria-hidden="true" />
&nbsp;Slett
</a>
</td>
</tr>
);
};
const EmailsTextarea = ({ list, selectedPrefix, newList = false }) => {
const [rawAliases, setRawAliases] = useState('');
const [aliases, setAliases] = useState([]);
const createAliases = () => {
console.log(list, aliases, selectedPrefix, newList);
// TODO: POST /aliases
};
return (
<>
<h5>
Eposter{' '}
<span className="badge badge-secondary email-counter">{aliases.length === 0 ? '' : aliases.length}</span>
</h5>
<textarea
className="form-control"
placeholder="asdf@studentersamfundet.no, qwerty@studentersamfundet.no f.eks. Klipp og lim så mye du orker"
rows="4"
value={rawAliases}
onChange={(e) => {
setRawAliases(e.target.value);
setAliases(parseEmails(e.target.value));
}}
/>
<div className="text-right">
<button type="button" className="btn btn-primary btn-add-list" onClick={createAliases}>
<span className="fas fa-plus" /> {newList ? 'Opprett liste' : 'Legg til'}
</button>
</div>
</>
);
};
const ForwardContainer = ({ filterQuery }) => {
// TODO: Filter aliases by filterQuery
const { data, loading, error } = useQuery(GET_ALIASES);
const [textareaVisible, setTextareaVisible] = useState(false);
const [deletionList, setdeletionList] = useState([]);
const deleteAliases = (aliases) => {
// TODO: delete aliases
console.log(aliases);
};
if (loading) {
return (
<div className="forwards-container">
<span className="fas fa-sync spin" aria-hidden="true" /> Laster...
</div>
);
}
if (error) {
return null;
}
const lists = groupBy(
sortBy(data.aliases, (alias) => {
return alias.source;
}),
(alias) => alias.source
);
return (
<div className="forwards-container">
<span className="fas fa-refresh spin" aria-hidden="true" /> Laster...
{Object.entries(lists).map(([list, aliases]) => {
return (
<div key={list} className="card">
<div className="card-header">
<span className="fas fa-list-alt icon-faded" aria-hidden="true" /> {list}{' '}
<span className="badge badge-secondary badge-list-total">{aliases.length}</span>
</div>
<table className="table table-striped table-hover table-condensed">
<tbody>
{aliases.map((alias) => (
<AliasRow
key={alias.id}
{...alias}
updateDeletionList={(aliasId, checked) => {
if (checked) {
setdeletionList([...deletionList, aliasId]); // add
} else {
setdeletionList(deletionList.filter((id) => id !== aliasId)); // remove
}
}}
/>
))}
<ActionRow
onAddEmailClick={() => {
setTextareaVisible(!textareaVisible);
}}
deleteVisible={Boolean(deletionList.length)}
onDelete={(e) => {
e.preventDefault();
deleteAliases(deletionList);
}}
/>
<tr className={ctx('textarea-row', { visible: textareaVisible })}>
<td colSpan="2">
<EmailsTextarea list={list} />
</td>
</tr>
</tbody>
</table>
</div>
);
})}
</div>
);
};
@@ -24,9 +240,9 @@ const Forwards = () => {
const MainArea = () => {
return (
<div className="col-sm-8">
<div className="orgunit-select-container"></div>
{/* <div className="orgunit-select-container"></div> */}
<ListCreate />
<Forwards />
<ForwardContainer />
</div>
);
};
@@ -1,8 +1,9 @@
import React, { useState } from 'react';
import React, { useState, useCallback } from 'react';
import { useQuery } from '@apollo/react-hooks';
import gql from 'graphql-tag';
import ctx from 'classnames';
import { setQueryString } from '../utils';
import { GET_ORG_UNITS } from '../queries';
const Search = ({ query, setQuery }) => {
return (
@@ -24,19 +25,21 @@ const OrgUnit = ({ id, name, selected, onClick }) => (
</li>
);
const GET_ORG_UNITS = gql`
query OrgUnits {
orgUnits @rest(type: "OrgUnit", path: "orgunits/") {
id
name
prefixes
}
}
`;
const OrgUnitList = () => {
const [query, setQuery] = useState(new URLSearchParams(window.location.search).get('q') || '');
const [selected, setSelected] = useState(null);
const { data, loading, error } = useQuery(GET_ORG_UNITS);
const setSearchQuery = useCallback(
(e) => {
e.preventDefault();
const q = e.target.value;
setQuery(q);
setQueryString(q ? { q } : null);
},
[setQuery]
);
if (loading || error) {
return null;
}
@@ -49,16 +52,8 @@ const OrgUnitList = () => {
return (
<div className="col-sm-4 sidebar">
<Search
query={query}
setQuery={(e) => {
e.preventDefault();
const q = e.target.value;
setQuery(q);
setQueryString(q ? { q } : null);
}}
/>
<h4>Foreninger/Utvalg</h4>
<Search query={query} setQuery={setSearchQuery} />
<h5>Foreninger/Utvalg</h5>
<ul className="nav nav-sidebar orgunit-list">
<li className={ctx({ active: selected === null })}>
<a
+51 -56
View File
@@ -4,6 +4,7 @@ $icon-font-path: "../fonts/";
@import "pnotify.core";
@import "./animations";
@import "./sidebar";
.login-form {
margin: 0 auto;
@@ -14,55 +15,24 @@ $icon-font-path: "../fonts/";
}
}
/* Hide for mobile, show later */
.sidebar {
.orgunit-list {
display: none;
@media (min-width: 768px) {
display: block;
}
}
}
.search-field {
margin-bottom: 20px;
}
.orgunit-list {
margin-bottom: 40px;
}
.orgunit-select-container {
display: block;
margin-bottom: 20px;
@media (min-width: 768px) {
display: none;
}
}
/* Sidebar navigation */
.nav-sidebar > .active > a,
.nav-sidebar > .active > a:hover,
.nav-sidebar > .active > a:focus {
color: #fff;
background-color: #428bca;
}
/* Sticky footer styles */
.footer {
margin-top: 40px;
}
/* Lists */
.del-cell {
text-align: center;
width: 82px;
padding: 0 !important;
text-align: right;
width: 54px;
padding: 2px 0 0 !important;
vertical-align: middle;
}
.del-label {
display: block;
height: 30px;
margin: 0;
text-align: center;
input {
margin: 0;
position: relative;
@@ -70,26 +40,28 @@ $icon-font-path: "../fonts/";
}
}
.forwards-container table > tbody > tr > td:first-child {
padding-left: 16px;
padding-right: 16px;
}
.forwards-container .badge-list-total {
.badge-list-total {
float: right;
margin-right: 14px;
position: relative;
top: 3px;
}
.new-list-btn {
margin-bottom: 20px;
margin-bottom: 1rem;
}
.new-list {
display: none;
margin-bottom: 1rem;
&.visible {
display: block;
}
}
.new-list-name {
padding-bottom: 5px;
}
.btn-add-list {
float: right;
margin-top: 10px;
margin-top: 1rem;
}
/* Forward list */
.panel-heading {
@@ -97,24 +69,43 @@ $icon-font-path: "../fonts/";
font-size: 16px;
}
.icon-faded {
font-size: 14px;
color: #ccc;
position: relative;
top: 1px;
margin-right: 4px;
top: 2px;
color: #ccc;
}
.forwards-container table > tbody > tr.action-row > td:first-child,
.table-condensed > tbody > tr.action-row > td {
.table-condensed {
margin-bottom: 0;
> tbody > tr > td {
padding: 5px;
&:first-child {
padding-left: 20px;
padding-right: 20px;
}
}
> tbody > tr.action-row > td {
padding: 0;
&:first-child {
padding: 0;
}
}
}
.fwdlist tr:nth-child(even) > td {
background-color: #f5f5f5;
.custom-control-label:after, .custom-control-label:before {
width: 1.3rem;
height: 1.3rem;
}
.action-row {
.link-add {
display: block;
}
.link-del {
white-space: nowrap;
display: none;
&.visible {
display: block;
}
@@ -153,13 +144,17 @@ $icon-font-path: "../fonts/";
left: 20px;
margin-top: 7px;
}
.card-header {
font-weight: bold;
}
/* Responsive fixes */
@media(max-width: 500px){
.panel-heading {
@media(max-width: map-get($grid-breakpoints, "sm")){
.card-header {
font-size: 13px;
font-weight: bold;
}
.panel-heading .icon-faded {
.card-header .icon-faded {
display: none;
}
td {
+31
View File
@@ -0,0 +1,31 @@
import gql from 'graphql-tag';
export const GET_ORG_UNITS = gql`
query OrgUnits {
orgUnits @rest(type: "OrgUnit", path: "orgunits/") {
id
name
prefixes
}
}
`;
// FIXME: only a single domain
export const GET_DOMAINS = gql`
query Domains {
domains @rest(type: "Domain", path: "domains/") {
id
name
}
}
`;
export const GET_ALIASES = gql`
query OrgUnits {
aliases @rest(type: "Alias", path: "aliases/") {
id
source
destination
}
}
`;
+8 -9
View File
@@ -1,5 +1,3 @@
/* global _ */
import PNotify from 'pnotify/dist/es/PNotify';
import * as queryString from 'query-string';
@@ -14,14 +12,15 @@ export function parseEmails(text) {
const re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
// For each of the words, add to the array of emails if the word matches the email regex.
let emails = _.filter(words, (word) => {
return word.match(re);
});
emails = _.map(emails, (el) => {
return el.toLowerCase();
});
const emails = words
.filter((word) => {
return word.match(re);
})
.map((el) => {
return el.toLowerCase();
});
return _.unique(emails);
return [...new Set(emails)];
}
export function setQueryString(obj) {
+2 -2
View File
@@ -1,7 +1,7 @@
from django.conf.urls import url
from rest_framework.routers import DefaultRouter
from mailadmin.views import MyUserViewSet, MyOrgUnitViewSet, OrgUnitViewSet, AliasesView, get_domain, index, lists
from mailadmin.views import MyUserViewSet, MyOrgUnitViewSet, OrgUnitViewSet, AliasesView, get_domains, index, lists
urlpatterns = [
url(r'^$', index, name='index'),
@@ -17,5 +17,5 @@ urlpatterns += router.urls
urlpatterns += [
url(r'^api/aliases/', AliasesView.as_view(), name='aliases'),
url(r'^api/emaildomain/', get_domain, name='email-domain'),
url(r'^api/domains/', get_domains, name='email-domains'),
]
+2 -2
View File
@@ -137,8 +137,8 @@ class OrgUnitViewSet(viewsets.ReadOnlyModelViewSet):
@api_view(['GET'])
def get_domain(request):
def get_domains(request):
api = DjangoPostfixDovecotAPI()
domains = api.list_domains(name=settings.NEUF_EMAIL_DOMAIN_NAME)
return Response({'domain': domains[0] if len(domains) == 1 else domains})
return Response(domains)
+2 -7
View File
@@ -86,11 +86,11 @@ USE_TZ = True
STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.StaticFilesStorage'
# TODO: add to local_settings.py
LOGIN_REDIRECT_URL = '/lists/'
DPD_API_URL = 'http://localhost'
# Django Postfix Dovecot
DPD_API_URL = 'http://localhost:8080/api'
DPD_API_USERNAME = ""
DPD_API_PASSWORD = ""
@@ -120,10 +120,6 @@ AUTH_LDAP_U_USER_FLAGS_BY_GROUP = {
"is_staff": "cn=edb,ou=Groups,dc=neuf,dc=no",
"is_superuser": "cn=edbadmin,ou=Groups,dc=neuf,dc=no"
}
# Group to profile flag mappings, not used.
AUTH_LDAP_U_PROFILE_FLAGS_BY_GROUP = {
#"is_edb": "cn=edb,ou=Groups,dc=neuf,dc=no"
}
# User attribute mappings
AUTH_LDAP_U_USER_ATTR_MAP = {
"first_name": "givenName",
@@ -148,7 +144,6 @@ AUTH_LDAP_E_GROUP_SEARCH = AUTH_LDAP_U_GROUP_SEARCH
AUTH_LDAP_E_GROUP_TYPE = AUTH_LDAP_U_GROUP_TYPE
AUTH_LDAP_E_MIRROR_GROUPS = AUTH_LDAP_U_MIRROR_GROUPS
AUTH_LDAP_E_USER_FLAGS_BY_GROUP = AUTH_LDAP_U_USER_FLAGS_BY_GROUP
AUTH_LDAP_E_PROFILE_FLAGS_BY_GROUP = AUTH_LDAP_U_PROFILE_FLAGS_BY_GROUP
AUTH_LDAP_E_USER_ATTR_MAP = AUTH_LDAP_U_USER_ATTR_MAP
AUTH_LDAP_E_PROFILE_ATTR_MAP = AUTH_LDAP_U_PROFILE_ATTR_MAP
AUTH_LDAP_E_ALWAYS_UPDATE_USER = AUTH_LDAP_U_ALWAYS_UPDATE_USER