1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-11 16:01:20 +00:00

Merge branch '427-small-changes-related-to-the-preview-of-templates-on-the-saas-website' into 'develop'

Resolve "Small changes related to the preview of templates on the SaaS website"

Closes 

See merge request 
This commit is contained in:
Bram Wiepjes 2021-04-20 12:44:56 +00:00
commit 1ac5931460
36 changed files with 871 additions and 555 deletions

View file

@ -6,6 +6,7 @@ from django.contrib.auth.models import update_last_login
from baserow.api.groups.invitations.serializers import UserGroupInvitationSerializer
from baserow.core.user.utils import normalize_email_address
from baserow.core.models import Template
User = get_user_model()
@ -37,6 +38,14 @@ class RegisterSerializer(serializers.Serializer):
help_text="If provided and valid, the user accepts the group invitation and "
"will have access to the group after signing up.",
)
template_id = serializers.PrimaryKeyRelatedField(
required=False,
default=None,
queryset=Template.objects.all(),
help_text="The id of the template that must be installed after creating the "
"account. This only works if the `group_invitation_token` param is not "
"provided.",
)
class SendResetPasswordEmailBodyValidationSerializer(serializers.Serializer):

View file

@ -31,7 +31,7 @@ from baserow.core.exceptions import (
GroupInvitationEmailMismatch,
GroupInvitationDoesNotExist,
)
from baserow.core.models import GroupInvitation
from baserow.core.models import GroupInvitation, Template
from baserow.core.user.handler import UserHandler
from baserow.core.user.exceptions import (
UserAlreadyExist,
@ -173,11 +173,18 @@ class UserView(APIView):
def post(self, request, data):
"""Registers a new user."""
template = (
Template.objects.get(pk=data["template_id"])
if data["template_id"]
else None
)
user = UserHandler().create_user(
name=data["name"],
email=data["email"],
password=data["password"],
group_invitation_token=data.get("group_invitation_token"),
template=template,
)
response = {"user": UserSerializer(user).data}

View file

@ -18,7 +18,7 @@ from .fields.field_types import (
class DatabasePlugin(Plugin):
type = "database"
def user_created(self, user, group, group_invitation):
def user_created(self, user, group, group_invitation, template):
"""
This method is called when a new user is created. We are going to create a
database, table, view, fields and some rows here as an example for the user.
@ -27,7 +27,7 @@ class DatabasePlugin(Plugin):
# If the user created an account in combination with a group invitation we
# don't want to create the initial data in the group because data should
# already exist.
if group_invitation:
if group_invitation or template:
return
core_handler = CoreHandler()

View file

@ -71,7 +71,7 @@ class Plugin(APIUrlsInstanceMixin, Instance):
return []
def user_created(self, user, group, group_invitation):
def user_created(self, user, group, group_invitation, template):
"""
A hook that is called after a new user has been created. This is the place to
create some data the user can start with. A group has already been created
@ -84,6 +84,9 @@ class Plugin(APIUrlsInstanceMixin, Instance):
:param group_invitation: Is provided if the user has signed up using a valid
group invitation token.
:type group_invitation: GroupInvitation
:param template: The template that is installed right after creating the
account. Is `None` if the template was not created.
:type template: Template or None
"""
def user_signed_in(self, user):

View file

@ -56,7 +56,9 @@ class UserHandler:
except User.DoesNotExist:
raise UserNotFound("The user with the provided parameters is not found.")
def create_user(self, name, email, password, group_invitation_token=None):
def create_user(
self, name, email, password, group_invitation_token=None, template=None
):
"""
Creates a new user with the provided information and creates a new group and
application for him. If the optional group invitation is provided then the user
@ -71,6 +73,9 @@ class UserHandler:
:param group_invitation_token: If provided and valid, the invitation will be
accepted and and initial group will not be created.
:type group_invitation_token: str
:param template: If provided, that template will be installed into the newly
created group.
:type template: Template
:raises: UserAlreadyExist: When a user with the provided username (email)
already exists.
:raises GroupInvitationEmailMismatch: If the group invitation email does not
@ -80,7 +85,9 @@ class UserHandler:
:rtype: User
"""
if not CoreHandler().get_settings().allow_new_signups:
core_handler = CoreHandler()
if not core_handler.get_settings().allow_new_signups:
raise DisabledSignupError("Sign up is disabled.")
email = normalize_email_address(email)
@ -88,7 +95,6 @@ class UserHandler:
if User.objects.filter(Q(email=email) | Q(username=email)).exists():
raise UserAlreadyExist(f"A user with username {email} already exists.")
core_handler = CoreHandler()
group_invitation = None
group_user = None
@ -120,9 +126,12 @@ class UserHandler:
if not group_user:
group_user = core_handler.create_group(user=user, name=f"{name}'s group")
if not group_invitation_token and template:
core_handler.install_template(user, group_user.group, template)
# Call the user_created method for each plugin that is un the registry.
for plugin in plugin_registry.registry.values():
plugin.user_created(user, group_user.group, group_invitation)
plugin.user_created(user, group_user.group, group_invitation, template)
return user

View file

@ -1,8 +1,11 @@
import os
import pytest
from freezegun import freeze_time
from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND
from django.contrib.auth import get_user_model
from django.shortcuts import reverse
from freezegun import freeze_time
from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST
from django.conf import settings
from baserow.contrib.database.models import Database, Table
from baserow.core.handler import CoreHandler
@ -99,7 +102,7 @@ def test_create_user_with_invitation(data_fixture, client):
},
format="json",
)
assert response_failed.status_code == 400
assert response_failed.status_code == HTTP_400_BAD_REQUEST
assert response_failed.json()["error"] == "BAD_TOKEN_SIGNATURE"
response_failed = client.post(
@ -112,7 +115,7 @@ def test_create_user_with_invitation(data_fixture, client):
},
format="json",
)
assert response_failed.status_code == 404
assert response_failed.status_code == HTTP_404_NOT_FOUND
assert response_failed.json()["error"] == "ERROR_GROUP_INVITATION_DOES_NOT_EXIST"
response_failed = client.post(
@ -125,7 +128,7 @@ def test_create_user_with_invitation(data_fixture, client):
},
format="json",
)
assert response_failed.status_code == 400
assert response_failed.status_code == HTTP_400_BAD_REQUEST
assert response_failed.json()["error"] == "ERROR_GROUP_INVITATION_EMAIL_MISMATCH"
assert User.objects.all().count() == 1
@ -139,7 +142,7 @@ def test_create_user_with_invitation(data_fixture, client):
},
format="json",
)
assert response_failed.status_code == 200
assert response_failed.status_code == HTTP_200_OK
assert User.objects.all().count() == 2
assert Group.objects.all().count() == 1
assert Group.objects.all().first().id == invitation.group_id
@ -148,6 +151,65 @@ def test_create_user_with_invitation(data_fixture, client):
assert Table.objects.all().count() == 0
@pytest.mark.django_db
def test_create_user_with_template(data_fixture, client):
old_templates = settings.APPLICATION_TEMPLATES_DIR
settings.APPLICATION_TEMPLATES_DIR = os.path.join(
settings.BASE_DIR, "../../../tests/templates"
)
template = data_fixture.create_template(slug="example-template")
response = client.post(
reverse("api:user:index"),
{
"name": "Test1",
"email": "test0@test.nl",
"password": "test12",
"template_id": -1,
},
format="json",
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
assert response_json["detail"]["template_id"][0]["code"] == "does_not_exist"
response = client.post(
reverse("api:user:index"),
{
"name": "Test1",
"email": "test0@test.nl",
"password": "test12",
"template_id": "random",
},
format="json",
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
assert response_json["detail"]["template_id"][0]["code"] == "incorrect_type"
response = client.post(
reverse("api:user:index"),
{
"name": "Test1",
"email": "test0@test.nl",
"password": "test12",
"template_id": template.id,
},
format="json",
)
assert response.status_code == HTTP_200_OK
assert Group.objects.all().count() == 2
assert GroupUser.objects.all().count() == 1
# We expect the example template to be installed
assert Database.objects.all().count() == 1
assert Database.objects.all().first().name == "Event marketing"
assert Table.objects.all().count() == 2
settings.APPLICATION_TEMPLATES_DIR = old_templates
@pytest.mark.django_db(transaction=True)
def test_send_reset_password_email(data_fixture, client, mailoutbox):
data_fixture.create_user(email="test@localhost.nl")

View file

@ -1,11 +1,13 @@
import os
from decimal import Decimal
from unittest.mock import MagicMock
import pytest
from django.contrib.auth import get_user_model
from freezegun import freeze_time
from itsdangerous.exc import SignatureExpired, BadSignature
from django.contrib.auth import get_user_model
from django.conf import settings
from baserow.contrib.database.models import (
Database,
Table,
@ -104,7 +106,7 @@ def test_create_user(data_fixture):
assert model_2_results[1].order == Decimal("2.00000000000000000000")
assert model_2_results[2].order == Decimal("3.00000000000000000000")
plugin_mock.user_created.assert_called_with(user, group, None)
plugin_mock.user_created.assert_called_with(user, group, None, None)
with pytest.raises(UserAlreadyExist):
user_handler.create_user("Test1", "test@test.nl", "password")
@ -169,6 +171,26 @@ def test_create_user_with_invitation(data_fixture):
assert Table.objects.all().count() == 0
@pytest.mark.django_db
def test_create_user_with_template(data_fixture):
old_templates = settings.APPLICATION_TEMPLATES_DIR
settings.APPLICATION_TEMPLATES_DIR = os.path.join(
settings.BASE_DIR, "../../../tests/templates"
)
template = data_fixture.create_template(slug="example-template")
user_handler = UserHandler()
user_handler.create_user("Test1", "test0@test.nl", "password", template=template)
assert Group.objects.all().count() == 2
assert GroupUser.objects.all().count() == 1
# We expect the example template to be installed
assert Database.objects.all().count() == 1
assert Database.objects.all().first().name == "Event marketing"
assert Table.objects.all().count() == 2
settings.APPLICATION_TEMPLATES_DIR = old_templates
@pytest.mark.django_db(transaction=True)
def test_send_reset_password_email(data_fixture, mailoutbox):
user = data_fixture.create_user(email="test@localhost")

View file

@ -6,6 +6,9 @@
* Switch to using a celery based email backend by default.
* Added `--add-columns` flag to the `fill_table` management command. It creates all the
field types before filling the table with random data.
* Make the view header more compact when the content doesn't fit anymore.
* Allow providing a `template_id` when registering a new account, which will install
that template instead of the default database.
## Released (2021-04-08)

View file

@ -75,16 +75,17 @@
.header__filter-item {
@extend %first-last-no-margin;
margin-left: 12px;
margin-left: 10px;
&.header__filter-item--right {
margin-left: auto;
margin-right: 12px;
margin-right: 10px;
}
}
.header__filter-link {
display: block;
display: flex;
align-items: center;
font-weight: 600;
color: $color-primary-900;
padding: 0 10px;
@ -115,10 +116,27 @@
}
}
.header--overflow .header__filter-name {
&:not(.header__filter-name--forced) {
display: none;
}
&.header__filter-name--forced {
@extend %ellipsis;
max-width: 120px;
margin-left: 4px;
}
}
.header__filter-icon {
color: $color-primary-900;
margin-right: 4px;
.header--overflow & {
margin-right: 0;
}
&.header-filter-icon--view {
color: $color-primary-500;
font-size: 14px;

View file

@ -1,6 +1,9 @@
.modal__wrapper {
@include absolute(0);
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
overflow: auto;
z-index: $z-index-modal;
background-color: rgba($color-neutral-700, 0.16);
@ -29,6 +32,10 @@
padding: 0;
}
&.modal__box--small {
max-width: 520px;
}
.box__title {
margin-top: 0;
}

View file

@ -66,12 +66,6 @@
padding: 0;
}
.templates__category {
&.templates__category--open {
// Nothing
}
}
.templates__category-link {
@extend %ellipsis;

View file

@ -82,6 +82,10 @@
user-select: initial !important;
}
.prevent-scroll {
overflow: hidden !important;
}
@keyframes spin {
0% {
transform: rotate(0);

View file

@ -188,6 +188,10 @@ export default {
target.classList.remove('forced-block')
}
// Take into account that the document might be scrollable.
verticalOffset += document.documentElement.scrollTop
horizontalOffset += document.documentElement.scrollLeft
// Calculate if top, bottom, left and right positions are possible.
const canTop = targetRect.top - contextRect.height - verticalOffset > 0
const canBottom =

View file

@ -10,6 +10,7 @@
:class="{
'modal__box--with-sidebar': sidebar,
'modal__box--full-screen': fullScreen,
'modal__box--small': small,
}"
>
<a v-if="closeButton" class="modal__close" @click="hide()">
@ -47,6 +48,11 @@ export default {
default: false,
required: false,
},
small: {
type: Boolean,
default: false,
required: false,
},
closeButton: {
type: Boolean,
default: true,

View file

@ -0,0 +1,163 @@
<template>
<div>
<div
v-if="invitation !== null"
class="alert alert--simple alert-primary alert--has-icon"
>
<div class="alert__icon">
<i class="fas fa-exclamation"></i>
</div>
<div class="alert__title">Invitation</div>
<p class="alert__content">
<strong>{{ invitation.invited_by }}</strong> has invited you to join
<strong>{{ invitation.group }}</strong
>.
</p>
</div>
<Error :error="error"></Error>
<form @submit.prevent="login">
<div class="control">
<label class="control__label">E-mail address</label>
<div class="control__elements">
<input
v-if="invitation !== null"
ref="email"
type="email"
class="input input--large"
disabled
:value="credentials.email"
/>
<input
v-else
ref="email"
v-model="credentials.email"
:class="{ 'input--error': $v.credentials.email.$error }"
type="email"
class="input input--large"
@blur="$v.credentials.email.$touch()"
/>
<div v-if="$v.credentials.email.$error" class="error">
Please enter a valid e-mail address.
</div>
</div>
</div>
<div class="control">
<label class="control__label">Password</label>
<div class="control__elements">
<input
ref="password"
v-model="credentials.password"
:class="{ 'input--error': $v.credentials.password.$error }"
type="password"
class="input input--large"
@blur="$v.credentials.password.$touch()"
/>
<div v-if="$v.credentials.password.$error" class="error">
A password is required.
</div>
</div>
</div>
<div class="actions">
<slot></slot>
<button
:class="{ 'button--loading': loading }"
class="button button--large"
:disabled="loading"
>
Sign in
<i class="fas fa-lock-open"></i>
</button>
</div>
</form>
</div>
</template>
<script>
import { required, email } from 'vuelidate/lib/validators'
import error from '@baserow/modules/core/mixins/error'
import GroupService from '@baserow/modules/core/services/group'
export default {
name: 'AuthLogin',
mixins: [error],
props: {
invitation: {
required: false,
validator: (prop) => typeof prop === 'object' || prop === null,
default: null,
},
},
data() {
return {
loading: false,
credentials: {
email: '',
password: '',
},
}
},
beforeMount() {
if (this.invitation !== null) {
this.credentials.email = this.invitation.email
}
},
methods: {
async login() {
this.$v.$touch()
if (this.$v.$invalid) {
return
}
this.loading = true
this.hideError()
try {
await this.$store.dispatch('auth/login', {
email: this.credentials.email,
password: this.credentials.password,
})
// If there is an invitation we can immediately accept that one after the user
// successfully signs in.
if (
this.invitation !== null &&
this.invitation.email === this.credentials.email
) {
await GroupService(this.$client).acceptInvitation(this.invitation.id)
}
this.$emit('success')
} catch (error) {
if (error.handler) {
const response = error.handler.response
// Because the API server does not yet respond with proper error codes we
// manually have to add the error here.
if (response && response.status === 400) {
this.showError(
'Incorrect credentials',
'The provided e-mail address or password is ' + 'incorrect.'
)
this.credentials.password = ''
this.$v.$reset()
this.$refs.password.focus()
} else {
const message = error.handler.getMessage('login')
this.showError(message)
}
this.loading = false
error.handler.handled()
} else {
throw error
}
}
},
},
validations: {
credentials: {
email: { required, email },
password: { required },
},
},
}
</script>

View file

@ -0,0 +1,223 @@
<template>
<div>
<div
v-if="invitation !== null"
class="alert alert--simple alert--primary alert--has-icon"
>
<div class="alert__icon">
<i class="fas fa-exclamation"></i>
</div>
<div class="alert__title">Invitation</div>
<p class="alert__content">
<strong>{{ invitation.invited_by }}</strong> has invited you to join
<strong>{{ invitation.group }}</strong
>.
</p>
</div>
<Error :error="error"></Error>
<form @submit.prevent="register">
<div class="control">
<label class="control__label">E-mail address</label>
<div class="control__elements">
<input
v-if="invitation !== null"
ref="email"
type="email"
class="input input--large"
disabled
:value="account.email"
/>
<input
v-else
ref="email"
v-model="account.email"
:class="{ 'input--error': $v.account.email.$error }"
type="text"
class="input input--large"
@blur="$v.account.email.$touch()"
/>
<div v-if="$v.account.email.$error" class="error">
Please enter a valid e-mail address.
</div>
</div>
</div>
<div class="control">
<label class="control__label">Your name</label>
<div class="control__elements">
<input
v-model="account.name"
:class="{ 'input--error': $v.account.name.$error }"
type="text"
class="input input--large"
@blur="$v.account.name.$touch()"
/>
<div v-if="$v.account.name.$error" class="error">
A minimum of two characters is required here.
</div>
</div>
</div>
<div class="control">
<label class="control__label">Password</label>
<div class="control__elements">
<input
v-model="account.password"
:class="{ 'input--error': $v.account.password.$error }"
type="password"
class="input input--large"
@blur="$v.account.password.$touch()"
/>
<div
v-if="$v.account.password.$error && !$v.account.password.required"
class="error"
>
A password is required.
</div>
<div
v-if="$v.account.password.$error && !$v.account.password.maxLength"
class="error"
>
A maximum of
{{ $v.account.password.$params.maxLength.max }} characters is
allowed here.
</div>
</div>
</div>
<div class="control">
<label class="control__label">Repeat password</label>
<div class="control__elements">
<input
v-model="account.passwordConfirm"
:class="{ 'input--error': $v.account.passwordConfirm.$error }"
type="password"
class="input input--large"
@blur="$v.account.passwordConfirm.$touch()"
/>
<div v-if="$v.account.passwordConfirm.$error" class="error">
This field must match your password field.
</div>
</div>
</div>
<div class="actions">
<slot></slot>
<button
:class="{ 'button--loading': loading }"
class="button button--large"
:disabled="loading"
>
Sign up
<i class="fas fa-user-plus"></i>
</button>
</div>
</form>
</div>
</template>
<script>
import {
email,
maxLength,
minLength,
required,
sameAs,
} from 'vuelidate/lib/validators'
import { ResponseErrorMessage } from '@baserow/modules/core/plugins/clientHandler'
import error from '@baserow/modules/core/mixins/error'
export default {
name: 'AuthRegister',
mixins: [error],
props: {
invitation: {
required: false,
validator: (prop) => typeof prop === 'object' || prop === null,
default: null,
},
template: {
required: false,
validator: (prop) => typeof prop === 'object' || prop === null,
default: null,
},
},
data() {
return {
loading: false,
account: {
email: '',
name: '',
password: '',
passwordConfirm: '',
},
}
},
beforeMount() {
if (this.invitation !== null) {
this.account.email = this.invitation.email
}
},
methods: {
async register() {
this.$v.$touch()
if (this.$v.$invalid) {
return
}
this.loading = true
this.hideError()
try {
const values = {
name: this.account.name,
email: this.account.email,
password: this.account.password,
}
// If there is a valid invitation we can add the group invitation token to the
// action parameters so that is can be passed along when signing up. That makes
// the user accept the group invitation without creating a new group for the
// user.
if (this.invitation !== null) {
values.groupInvitationToken = this.$route.query.groupInvitationToken
}
// If a template is provided, we can add that id to the parameters so that the
// template will be installed right while creating the account. This is going
// to done instead of the default example template.
if (this.template !== null) {
values.templateId = this.template.id
}
await this.$store.dispatch('auth/register', values)
Object.values(this.$registry.getAll('plugin')).forEach((plugin) => {
plugin.userCreated(this.account, this)
})
this.$emit('success')
} catch (error) {
this.loading = false
this.handleError(error, 'signup', {
ERROR_EMAIL_ALREADY_EXISTS: new ResponseErrorMessage(
'User already exists.',
'A user with the provided e-mail address already exists.'
),
})
}
},
},
validations: {
account: {
email: { required, email },
name: {
required,
minLength: minLength(2),
},
password: {
required,
maxLength: maxLength(256),
},
passwordConfirm: {
sameAsPassword: sameAs('password'),
},
},
},
}
</script>

View file

@ -122,7 +122,7 @@
<GroupsContext ref="groupSelect"></GroupsContext>
</div>
</li>
<li class="tree__item">
<li v-if="selectedGroup.permissions === 'ADMIN'" class="tree__item">
<div class="tree__action">
<a class="tree__link" @click="$refs.groupMembersModal.show()">
<i class="tree__icon tree__icon--type fas fa-users"></i>

View file

@ -1,110 +0,0 @@
<template>
<div class="templates__body">
<template v-if="template !== null">
<div v-if="loading" class="loading-absolute-center"></div>
<div v-else class="layout">
<div class="layout__col-1">
<TemplateSidebar
:template="template"
:applications="applications"
:page="page"
@selected-page="selectPage"
></TemplateSidebar>
</div>
<div class="layout__col-2">
<component
:is="pageComponent"
v-if="page !== null"
:page-value="page.value"
></component>
</div>
</div>
</template>
</div>
</template>
<script>
import { notifyIf } from '@baserow/modules/core/utils/error'
import ApplicationService from '@baserow/modules/core/services/application'
import { populateApplication } from '@baserow/modules/core/store/application'
import TemplateSidebar from '@baserow/modules/core/components/template/TemplateSidebar'
export default {
name: 'TemplateBody',
components: { TemplateSidebar },
props: {
template: {
required: true,
validator: (prop) => typeof prop === 'object' || prop === null,
},
},
data() {
return {
loading: false,
applications: [],
page: null,
}
},
computed: {
pageComponent() {
if (this.page !== null) {
return this.$registry
.get('application', this.page.application)
.getTemplatesPageComponent()
}
return null
},
},
watch: {
template(value) {
if (value === null) {
this.loading = false
this.applications = []
this.page = null
return
}
this.fetchApplications(value)
},
},
methods: {
async fetchApplications(template) {
this.loading = true
try {
const { data } = await ApplicationService(this.$client).fetchAll(
template.group_id
)
data.forEach((application) => {
populateApplication(application, this.$registry)
})
this.applications = data
// Check if there is an application that can give us an initial page. The
// database application type would for example return the first table as page.
for (let i = 0; i < this.applications.length; i++) {
const application = this.applications[i]
const pageValue = this.$registry
.get('application', application.type)
.getTemplatePage(application)
if (pageValue !== null) {
application._.selected = true
this.selectPage({
application: application.type,
value: pageValue,
})
break
}
}
} catch (error) {
this.applications = []
notifyIf(error, 'templates')
} finally {
this.loading = false
}
},
selectPage({ application, value }) {
this.page = { application, value }
},
},
}
</script>

View file

@ -52,53 +52,16 @@
</template>
<script>
import { escapeRegExp } from '@baserow/modules/core/utils/string'
import { clone } from '@baserow/modules/core/utils/object'
import templateCategories from '@baserow/modules/core/mixins/templateCategories'
export default {
name: 'TemplateCategories',
mixins: [templateCategories],
props: {
categories: {
type: Array,
required: true,
},
selectedTemplate: {
required: true,
validator: (prop) => typeof prop === 'object' || prop === null,
},
},
data() {
return {
search: '',
selectedCategoryId: -1,
}
},
computed: {
/**
* Returns the categories and templates that match the search query.
*/
matchingCategories() {
if (this.search === '') {
return this.categories
}
return clone(this.categories)
.map((category) => {
category.templates = category.templates.filter((template) => {
const keywords = template.keywords.split(',')
keywords.push(template.name)
const regex = new RegExp('(' + escapeRegExp(this.search) + ')', 'i')
return keywords.some((value) => value.match(regex))
})
return category
})
.filter((category) => category.templates.length > 0)
},
},
methods: {
selectCategory(id) {
this.selectedCategoryId = this.selectedCategoryId === id ? -1 : id
},
},
}
</script>

View file

@ -61,7 +61,7 @@ export default {
this.$store.dispatch('application/forceCreate', application)
})
if (data.length > 0) {
// If there are applications we want to select the first one right away.
// If there are applications, we want to select the first one right away.
const application = this.$store.getters['application/get'](data[0].id)
const type = this.$registry.get('application', application.type)
type.select(application, this)

View file

@ -18,7 +18,10 @@
<i class="fas fa-times"></i>
</a>
</TemplateCategories>
<TemplateBody :template="selectedTemplate"></TemplateBody>
<TemplatePreview
:template="selectedTemplate"
class="templates__body"
></TemplatePreview>
</template>
</Modal>
</template>
@ -30,11 +33,11 @@ import { notifyIf } from '@baserow/modules/core/utils/error'
import TemplateHeader from '@baserow/modules/core/components/template/TemplateHeader'
import TemplateCategories from '@baserow/modules/core/components/template/TemplateCategories'
import TemplateBody from '@baserow/modules/core/components/template/TemplateBody'
import TemplatePreview from '@baserow/modules/core/components/template/TemplatePreview'
export default {
name: 'TemplateModal',
components: { TemplateHeader, TemplateCategories, TemplateBody },
components: { TemplateHeader, TemplateCategories, TemplatePreview },
mixins: [modal],
props: {
group: {

View file

@ -1,9 +1,127 @@
<template>
<div>@TODO</div>
<div>
<template v-if="template !== null">
<div v-if="loading" class="loading-absolute-center"></div>
<div v-else class="layout" :class="{ 'layout--collapsed': collapsed }">
<div class="layout__col-1">
<TemplateSidebar
:template="template"
:applications="applications"
:page="page"
:collapsed="collapsed"
@selected-page="selectPage"
@collapse-toggled="collapsed = !collapsed"
></TemplateSidebar>
</div>
<div class="layout__col-2">
<component
:is="pageComponent"
v-if="page !== null"
:page-value="page.value"
></component>
</div>
</div>
</template>
</div>
</template>
<script>
import { notifyIf } from '@baserow/modules/core/utils/error'
import ApplicationService from '@baserow/modules/core/services/application'
import { populateApplication } from '@baserow/modules/core/store/application'
import TemplateSidebar from '@baserow/modules/core/components/template/TemplateSidebar'
export default {
name: 'TemplatesBody',
name: 'TemplatePreview',
components: { TemplateSidebar },
props: {
template: {
required: true,
validator: (prop) => typeof prop === 'object' || prop === null,
},
},
data() {
return {
loading: false,
applications: [],
page: null,
collapsed: false,
}
},
computed: {
pageComponent() {
if (this.page !== null) {
return this.$registry
.get('application', this.page.application)
.getTemplatesPageComponent()
}
return null
},
},
watch: {
template(value) {
if (value === null) {
this.loading = false
this.applications = []
this.page = null
return
}
this.fetchApplications(value)
},
},
created() {
if (this.template !== null) {
this.fetchApplications(this.template)
}
},
mounted() {
this.$nextTick(() => {
// If the window doesn't have that much space, we want to start with the sidebar
// collapsed.
if (window.outerWidth < 640) {
this.collapsed = true
}
})
},
methods: {
async fetchApplications(template) {
this.loading = true
try {
const { data } = await ApplicationService(this.$client).fetchAll(
template.group_id
)
data.forEach((application) => {
populateApplication(application, this.$registry)
})
this.applications = data
// Check if there is an application that can give us an initial page. The
// database application type would for example return the first table as page.
for (let i = 0; i < this.applications.length; i++) {
const application = this.applications[i]
const pageValue = this.$registry
.get('application', application.type)
.getTemplatePage(application)
if (pageValue !== null) {
application._.selected = true
this.selectPage({
application: application.type,
value: pageValue,
})
break
}
}
} catch (error) {
this.applications = []
notifyIf(error, 'templates')
} finally {
this.loading = false
}
},
selectPage({ application, value }) {
this.page = { application, value }
},
},
}
</script>

View file

@ -1,6 +1,6 @@
<template>
<div class="sidebar">
<div class="sidebar__nav">
<div v-show="!collapsed" class="sidebar__nav">
<ul class="tree">
<li class="tree__item margin-top-2">
<div class="tree__link tree__link--group">
@ -18,6 +18,24 @@
></component>
</ul>
</div>
<div class="sidebar__foot">
<div class="sidebar__logo">
<img
height="14"
src="@baserow/modules/core/static/img/logo.svg"
alt="Baserow logo"
/>
</div>
<a class="sidebar__collapse-link" @click="$emit('collapse-toggled')">
<i
class="fas"
:class="{
'fa-angle-double-right': collapsed,
'fa-angle-double-left': !collapsed,
}"
></i>
</a>
</div>
</div>
</template>
@ -37,6 +55,10 @@ export default {
required: true,
validator: (prop) => typeof prop === 'object' || prop === null,
},
collapsed: {
type: Boolean,
required: true,
},
},
methods: {
getApplicationComponent(application) {

View file

@ -32,6 +32,7 @@ export default {
this.open = true
this.$emit('show')
window.addEventListener('keyup', this.keyup)
document.body.classList.add('prevent-scroll')
},
/**
* Hide the modal.
@ -52,6 +53,7 @@ export default {
}
window.removeEventListener('keyup', this.keyup)
document.body.classList.remove('prevent-scroll')
},
/**
* If someone actually clicked on the modal wrapper and not one of his children the

View file

@ -0,0 +1,44 @@
import { clone } from '@baserow/modules/core/utils/object'
import { escapeRegExp } from '@baserow/modules/core/utils/string'
export default {
props: {
categories: {
type: Array,
required: true,
},
},
data() {
return {
search: '',
selectedCategoryId: -1,
}
},
computed: {
/**
* Returns the categories and templates that match the search query.
*/
matchingCategories() {
if (this.search === '') {
return this.categories
}
return clone(this.categories)
.map((category) => {
category.templates = category.templates.filter((template) => {
const keywords = template.keywords.split(',')
keywords.push(template.name)
const regex = new RegExp('(' + escapeRegExp(this.search) + ')', 'i')
return keywords.some((value) => value.match(regex))
})
return category
})
.filter((category) => category.templates.length > 0)
},
},
methods: {
selectCategory(id) {
this.selectedCategoryId = this.selectedCategoryId === id ? -1 : id
},
},
}

View file

@ -5,107 +5,31 @@
<img src="@baserow/modules/core/static/img/logo.svg" alt="" />
</nuxt-link>
</h1>
<div
v-if="invitation !== null"
class="alert alert--simple alert-primary alert--has-icon"
>
<div class="alert__icon">
<i class="fas fa-exclamation"></i>
</div>
<div class="alert__title">Invitation</div>
<p class="alert__content">
<strong>{{ invitation.invited_by }}</strong> has invited you to join
<strong>{{ invitation.group }}</strong
>.
</p>
</div>
<Error :error="error"></Error>
<form @submit.prevent="login">
<div class="control">
<label class="control__label">E-mail address</label>
<div class="control__elements">
<input
v-if="invitation !== null"
ref="email"
type="email"
class="input input--large"
disabled
:value="credentials.email"
/>
<input
v-else
ref="email"
v-model="credentials.email"
:class="{ 'input--error': $v.credentials.email.$error }"
type="email"
class="input input--large"
@blur="$v.credentials.email.$touch()"
/>
<div v-if="$v.credentials.email.$error" class="error">
Please enter a valid e-mail address.
</div>
</div>
</div>
<div class="control">
<label class="control__label">Password</label>
<div class="control__elements">
<input
ref="password"
v-model="credentials.password"
:class="{ 'input--error': $v.credentials.password.$error }"
type="password"
class="input input--large"
@blur="$v.credentials.password.$touch()"
/>
<div v-if="$v.credentials.password.$error" class="error">
A password is required.
</div>
</div>
</div>
<div class="actions">
<ul class="action__links">
<li v-if="settings.allow_new_signups">
<nuxt-link :to="{ name: 'signup' }"> Sign up </nuxt-link>
</li>
<li>
<nuxt-link :to="{ name: 'forgot-password' }">
Forgot password
</nuxt-link>
</li>
</ul>
<button
:class="{ 'button--loading': loading }"
class="button button--large"
:disabled="loading"
>
Sign in
<i class="fas fa-lock-open"></i>
</button>
</div>
</form>
<AuthLogin :invitation="invitation" @success="success">
<ul class="action__links">
<li v-if="settings.allow_new_signups">
<nuxt-link :to="{ name: 'signup' }"> Sign up </nuxt-link>
</li>
<li>
<nuxt-link :to="{ name: 'forgot-password' }">
Forgot password
</nuxt-link>
</li>
</ul>
</AuthLogin>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import { required, email } from 'vuelidate/lib/validators'
import error from '@baserow/modules/core/mixins/error'
import AuthLogin from '@baserow/modules/core/components/auth/AuthLogin'
import groupInvitationToken from '@baserow/modules/core/mixins/groupInvitationToken'
import GroupService from '@baserow/modules/core/services/group'
export default {
mixins: [error, groupInvitationToken],
components: { AuthLogin },
mixins: [groupInvitationToken],
layout: 'login',
data() {
return {
loading: false,
credentials: {
email: '',
password: '',
},
}
},
head() {
return {
title: 'Login',
@ -122,72 +46,14 @@ export default {
settings: 'settings/get',
}),
},
beforeMount() {
if (this.invitation !== null) {
this.credentials.email = this.invitation.email
}
},
methods: {
async login() {
this.$v.$touch()
if (this.$v.$invalid) {
return
success() {
const { original } = this.$route.query
if (original) {
this.$nuxt.$router.push(original)
} else {
this.$nuxt.$router.push({ name: 'dashboard' })
}
this.loading = true
this.hideError()
try {
await this.$store.dispatch('auth/login', {
email: this.credentials.email,
password: this.credentials.password,
})
// If there is an invitation we can immediately accept that one after the user
// successfully signs in.
if (
this.invitation !== null &&
this.invitation.email === this.credentials.email
) {
await GroupService(this.$client).acceptInvitation(this.invitation.id)
}
const { original } = this.$route.query
if (original) {
this.$nuxt.$router.push(original)
} else {
this.$nuxt.$router.push({ name: 'dashboard' })
}
} catch (error) {
if (error.handler) {
const response = error.handler.response
// Because the API server does not yet respond with proper error codes we
// manually have to add the error here.
if (response && response.status === 400) {
this.showError(
'Incorrect credentials',
'The provided e-mail address or password is ' + 'incorrect.'
)
this.credentials.password = ''
this.$v.$reset()
this.$refs.password.focus()
} else {
const message = error.handler.getMessage('login')
this.showError(message)
}
this.loading = false
error.handler.handled()
} else {
throw error
}
}
},
},
validations: {
credentials: {
email: { required, email },
password: { required },
},
},
}

View file

@ -19,157 +19,28 @@
Back to login
</nuxt-link>
</template>
<template v-else>
<div
v-if="invitation !== null"
class="alert alert--simple alert--primary alert--has-icon"
>
<div class="alert__icon">
<i class="fas fa-exclamation"></i>
</div>
<div class="alert__title">Invitation</div>
<p class="alert__content">
<strong>{{ invitation.invited_by }}</strong> has invited you to join
<strong>{{ invitation.group }}</strong
>.
</p>
</div>
<Error :error="error"></Error>
<form @submit.prevent="register">
<div class="control">
<label class="control__label">E-mail address</label>
<div class="control__elements">
<input
v-if="invitation !== null"
ref="email"
type="email"
class="input input--large"
disabled
:value="account.email"
/>
<input
v-else
ref="email"
v-model="account.email"
:class="{ 'input--error': $v.account.email.$error }"
type="text"
class="input input--large"
@blur="$v.account.email.$touch()"
/>
<div v-if="$v.account.email.$error" class="error">
Please enter a valid e-mail address.
</div>
</div>
</div>
<div class="control">
<label class="control__label">Your name</label>
<div class="control__elements">
<input
v-model="account.name"
:class="{ 'input--error': $v.account.name.$error }"
type="text"
class="input input--large"
@blur="$v.account.name.$touch()"
/>
<div v-if="$v.account.name.$error" class="error">
A minimum of two characters is required here.
</div>
</div>
</div>
<div class="control">
<label class="control__label">Password</label>
<div class="control__elements">
<input
v-model="account.password"
:class="{ 'input--error': $v.account.password.$error }"
type="password"
class="input input--large"
@blur="$v.account.password.$touch()"
/>
<div
v-if="$v.account.password.$error && !$v.account.password.required"
class="error"
>
A password is required.
</div>
<div
v-if="
$v.account.password.$error && !$v.account.password.maxLength
"
class="error"
>
A maximum of
{{ $v.account.password.$params.maxLength.max }} characters is
allowed here.
</div>
</div>
</div>
<div class="control">
<label class="control__label">Repeat password</label>
<div class="control__elements">
<input
v-model="account.passwordConfirm"
:class="{ 'input--error': $v.account.passwordConfirm.$error }"
type="password"
class="input input--large"
@blur="$v.account.passwordConfirm.$touch()"
/>
<div v-if="$v.account.passwordConfirm.$error" class="error">
This field must match your password field.
</div>
</div>
</div>
<div class="actions">
<ul class="action__links">
<li>
<nuxt-link :to="{ name: 'login' }">
<i class="fas fa-arrow-left"></i>
Back
</nuxt-link>
</li>
</ul>
<button
:class="{ 'button--loading': loading }"
class="button button--large"
:disabled="loading"
>
Sign up
<i class="fas fa-user-plus"></i>
</button>
</div>
</form>
</template>
<AuthRegister v-else :invitation="invitation" @success="success">
<ul class="action__links">
<li>
<nuxt-link :to="{ name: 'login' }">
<i class="fas fa-arrow-left"></i>
Back
</nuxt-link>
</li>
</ul>
</AuthRegister>
</div>
</template>
<script>
import {
required,
email,
sameAs,
minLength,
maxLength,
} from 'vuelidate/lib/validators'
import { ResponseErrorMessage } from '@baserow/modules/core/plugins/clientHandler'
import groupInvitationToken from '@baserow/modules/core/mixins/groupInvitationToken'
import error from '@baserow/modules/core/mixins/error'
import { mapGetters } from 'vuex'
import groupInvitationToken from '@baserow/modules/core/mixins/groupInvitationToken'
import AuthRegister from '@baserow/modules/core/components/auth/AuthRegister'
export default {
mixins: [error, groupInvitationToken],
components: { AuthRegister },
mixins: [groupInvitationToken],
layout: 'login',
data() {
return {
loading: false,
account: {
email: '',
name: '',
password: '',
passwordConfirm: '',
},
}
},
head() {
return {
title: 'Create new account',
@ -180,66 +51,9 @@ export default {
settings: 'settings/get',
}),
},
beforeMount() {
if (this.invitation !== null) {
this.account.email = this.invitation.email
}
},
methods: {
async register() {
this.$v.$touch()
if (this.$v.$invalid) {
return
}
this.loading = true
this.hideError()
try {
const values = {
name: this.account.name,
email: this.account.email,
password: this.account.password,
}
// If there is a valid invitation we can add the group invitation token to the
// action parameters so that is can be passed along when signing up. That makes
// the user accept the group invitation without creating a new group for the
// user.
if (this.invitation !== null) {
values.groupInvitationToken = this.$route.query.groupInvitationToken
}
await this.$store.dispatch('auth/register', values)
Object.values(this.$registry.getAll('plugin')).forEach((plugin) => {
plugin.userCreated(this.account, this)
})
this.$nuxt.$router.push({ name: 'dashboard' })
} catch (error) {
this.loading = false
this.handleError(error, 'signup', {
ERROR_EMAIL_ALREADY_EXISTS: new ResponseErrorMessage(
'User already exists.',
'A user with the provided e-mail address already exists.'
),
})
}
},
},
validations: {
account: {
email: { required, email },
name: {
required,
minLength: minLength(2),
},
password: {
required,
maxLength: maxLength(256),
},
passwordConfirm: {
sameAsPassword: sameAs('password'),
},
success() {
this.$nuxt.$router.push({ name: 'dashboard' })
},
},
}

View file

@ -16,7 +16,8 @@ export default (client) => {
name,
password,
authenticate = true,
groupInvitationToken = null
groupInvitationToken = null,
templateId = null
) {
const values = {
name,
@ -29,6 +30,10 @@ export default (client) => {
values.group_invitation_token = groupInvitationToken
}
if (templateId !== null) {
values.template_id = templateId
}
return client.post('/user/', values)
},
sendResetPasswordEmail(email, baseUrl) {

View file

@ -46,14 +46,15 @@ export const actions = {
*/
async register(
{ commit, dispatch },
{ email, name, password, groupInvitationToken = null }
{ email, name, password, groupInvitationToken = null, templateId = null }
) {
const { data } = await AuthService(this.$client).register(
email,
name,
password,
true,
groupInvitationToken
groupInvitationToken,
templateId
)
setToken(data.token, this.app)
commit('SET_USER_DATA', data)

View file

@ -69,9 +69,9 @@ export const actions = {
* If not already loading or loaded it will trigger the fetchAll action which
* will load all the groups for the user.
*/
loadAll({ state, dispatch }) {
async loadAll({ state, dispatch }) {
if (!state.loaded && !state.loading) {
dispatch('fetchAll')
await dispatch('fetchAll')
}
},
/**
@ -111,6 +111,7 @@ export const actions = {
async create({ commit, dispatch }, values) {
const { data } = await GroupService(this.$client).create(values)
dispatch('forceCreate', data)
return data
},
/**
* Forcefully create an item in the store without making a call to the server.

View file

@ -1,6 +1,10 @@
<template>
<div>
<header class="layout__col-2-1 header">
<header
ref="header"
class="layout__col-2-1 header"
:class="{ 'header--overflow': headerOverflow }"
>
<div v-show="tableLoading" class="header__loading"></div>
<ul v-if="!tableLoading" class="header__filter">
<li class="header__filter-item header__filter-item--grids">
@ -16,13 +20,15 @@
)
"
>
<span v-if="hasSelectedView">
<template v-if="hasSelectedView">
<i
class="header__filter-icon header-filter-icon--view fas"
:class="'fa-' + view._.type.iconClass"
></i>
{{ view.name }}
</span>
<span class="header__filter-name header__filter-name--forced">{{
view.name
}}</span>
</template>
<span v-else>
<i
class="header__filter-icon header-filter-icon-no-choice fas fa-caret-square-down"
@ -35,6 +41,7 @@
:table="table"
:views="views"
:read-only="readOnly"
:header-overflow="headerOverflow"
@selected-view="$emit('selected-view', $event)"
></ViewsContext>
</li>
@ -96,6 +103,8 @@
</template>
<script>
import ResizeObserver from 'resize-observer-polyfill'
import { RefreshCancelledError } from '@baserow/modules/core/errors'
import { notifyIf } from '@baserow/modules/core/utils/error'
import ViewsContext from '@baserow/modules/database/components/view/ViewsContext'
@ -143,8 +152,9 @@ export default {
required: true,
},
view: {
validator: (prop) => typeof prop === 'object' || prop === undefined,
type: Object,
required: true,
validator: (prop) => typeof prop === 'object' || prop === undefined,
},
tableLoading: {
type: Boolean,
@ -165,6 +175,9 @@ export default {
return {
// Shows a small spinning loading animation when the view is being refreshed.
viewLoading: false,
// Indicates if the elements within the header are overflowing. In case of true,
// we can hide certain values to make sure that it fits within the header.
headerOverflow: false,
}
},
computed: {
@ -179,11 +192,25 @@ export default {
)
},
},
watch: {
tableLoading(value) {
if (!value) {
this.$nextTick(() => {
this.checkHeaderOverflow()
})
}
},
},
beforeMount() {
this.$bus.$on('table-refresh', this.refresh)
},
mounted() {
this.$el.resizeObserver = new ResizeObserver(this.checkHeaderOverflow)
this.$el.resizeObserver.observe(this.$el)
},
beforeDestroy() {
this.$bus.$off('table-refresh', this.refresh)
this.$el.resizeObserver.unobserve(this.$el)
},
methods: {
getViewComponent(view) {
@ -194,6 +221,26 @@ export default {
const type = this.$registry.get('view', view.type)
return type.getHeaderComponent()
},
/**
* When the window resizes, we want to check if the content of the header is
* overflowing. If that is the case, we want to make some space by removing some
* content. We do this by copying the header content into a new HTMLElement and
* check if the elements still fit within the header. We copy the html because we
* want to measure the header in the full width state.
*/
checkHeaderOverflow() {
const header = this.$refs.header
const width = header.getBoundingClientRect().width
const wrapper = document.createElement('div')
wrapper.innerHTML = header.outerHTML
const el = wrapper.childNodes[0]
el.style = `position: absolute; left: 0; top: 0; width: ${width}px; overflow: auto;`
el.classList.remove('header--overflow')
document.body.appendChild(el)
this.headerOverflow =
el.clientWidth < el.scrollWidth || el.clientHeight < el.scrollHeight
document.body.removeChild(el)
},
/**
* Refreshes the whole view. All data will be reloaded and it will visually look
* the same as seeing the view for the first time.

View file

@ -9,7 +9,7 @@
@click="$refs.context.toggle($refs.contextLink, 'bottom', 'left', 4)"
>
<i class="header__filter-icon fas fa-filter"></i>
{{ filterTitle }}
<span class="header__filter-name">{{ filterTitle }}</span>
</a>
<ViewFilterContext
ref="context"

View file

@ -9,7 +9,7 @@
@click="$refs.context.toggle($refs.contextLink, 'bottom', 'left', 4)"
>
<i class="header__filter-icon fas fa-sort"></i>
{{ sortTitle }}
<span class="header__filter-name">{{ sortTitle }}</span>
</a>
<ViewSortContext
ref="context"

View file

@ -9,7 +9,7 @@
@click="$refs.context.toggle($refs.contextLink, 'bottom', 'left', 4)"
>
<i class="header__filter-icon fas fa-eye-slash"></i>
{{ hiddenFieldsTitle }}
<span class="header__filter-name">{{ hiddenFieldsTitle }}</span>
</a>
<GridViewHideContext
ref="context"

View file

@ -30,6 +30,7 @@
"nuxt": "^2.14.12",
"nuxt-env": "^0.1.0",
"papaparse": "^5.3.0",
"resize-observer-polyfill": "^1.5.1",
"sass-loader": "^10.1.1",
"thenby": "^1.3.4",
"vuejs-datepicker": "^1.6.2",

View file

@ -9989,6 +9989,11 @@ reserved-words@^0.1.2:
resolved "https://registry.yarnpkg.com/reserved-words/-/reserved-words-0.1.2.tgz#00a0940f98cd501aeaaac316411d9adc52b31ab1"
integrity sha1-AKCUD5jNUBrqqsMWQR2a3FKzGrE=
resize-observer-polyfill@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464"
integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==
resolve-cwd@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d"