Merge pull request #79 from Human-Connection/admin-tags-and-categories

Admin tags and categories
This commit is contained in:
Robert Schäfer 2018-12-13 13:33:22 +01:00 committed by GitHub
commit 010bac50ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1434 additions and 156 deletions

View File

@ -20,13 +20,15 @@ before_install:
install:
- docker build --build-arg BUILD_COMMIT=$TRAVIS_COMMIT -t humanconnection/nitro-web .
- docker-compose -f docker-compose.yml up -d
- git clone --depth=50 https://github.com/Human-Connection/Nitro-Backend.git ../Nitro-Backend
- git --work-tree=../Nitro-Backend checkout $TRAVIS_BRANCH || echo "Branch \`$TRAVIS_BRANCH\` does not exist, falling back to \`master\`"
- git clone https://github.com/Human-Connection/Nitro-Backend.git ../Nitro-Backend
- git -C "../Nitro-Backend" checkout $TRAVIS_BRANCH || echo "Branch \`$TRAVIS_BRANCH\` does not exist, falling back to \`master\`"
- docker-compose -f ../Nitro-Backend/docker-compose.yml up -d
- yarn global add cypress wait-on
- yarn add cypress-cucumber-preprocessor
script:
- docker-compose exec webapp yarn run lint
- docker-compose exec webapp yarn run test
- docker-compose -f ../Nitro-Backend/docker-compose.yml exec backend yarn run db:seed > /dev/null
- wait-on http://localhost:3000
- cypress run --record --key $CYPRESS_TOKEN

View File

@ -118,3 +118,11 @@ blockquote {
margin-top: 0;
}
}
hr {
border: 0;
width: 100%;
color: $color-neutral-80;
background-color: $color-neutral-80;
height: 1px !important;
}

View File

@ -31,13 +31,23 @@
</no-ssr>
</ds-space>
<template slot="footer">
<span :style="{ opacity: post.shoutedCount ? 1 : .5 }">
<ds-icon name="heart-o" /> <small>{{ post.shoutedCount }}</small>
</span>
&nbsp;
<span :style="{ opacity: post.commentsCount ? 1 : .5 }">
<ds-icon name="comments" /> <small>{{ post.commentsCount }}</small>
</span>
<div style="display: inline-block; opacity: .5;">
<ds-icon
v-for="category in post.categories"
:key="category.id"
v-tooltip="{content: category.name, placement: 'bottom-start', delay: { show: 500 }}"
:name="category.icon"
/>&nbsp;
</div>
<div style="display: inline-block; float: right">
<span :style="{ opacity: post.shoutedCount ? 1 : .5 }">
<ds-icon name="bullhorn" /> <small>{{ post.shoutedCount }}</small>
</span>
&nbsp;
<span :style="{ opacity: post.commentsCount ? 1 : .5 }">
<ds-icon name="comments" /> <small>{{ post.commentsCount }}</small>
</span>
</div>
</template>
</ds-card>
</a>

View File

@ -7,7 +7,7 @@
:disabled="disabled || loading"
danger
size="x-large"
icon="heart"
icon="bullhorn"
@click="shout"
/>
<ds-space margin-bottom="xx-small" />

View File

@ -1,3 +1,4 @@
{
"projectId": "qa7fe2"
"projectId": "qa7fe2",
"ignoreTestFiles": "*.js"
}

View File

@ -0,0 +1,25 @@
Feature: Authentication
As a database administrator
I want users to sign in
In order to attribute posts and other contributions to their authors
Background:
Given my account has the following details:
| name | email | password |
| Peter Lustig | admin@example.org | 1234 |
Scenario: Log in
When I visit the "/login" page
And I fill in my email and password combination and click submit
Then I can click on my profile picture in the top right corner
And I can see my name "Peter Lustig" in the dropdown menu
Scenario: Refresh and stay logged in
Given I am logged in
When I refresh the page
Then I am still logged in
Scenario: Log out
Given I am logged in
When I log out through the menu in the top right corner
Then I see the login screen again

View File

@ -0,0 +1,41 @@
Feature: Tags and Categories
As a database administrator
I would like to see a summary of all tags and categories and their usage
In order to be able to decide which tags and categories are popular or not
The currently deployed application, codename "Alpha", distinguishes between
categories and tags. Each post can have a number of categories and/or tags.
A few categories are required for each post, tags are completely optional.
Both help to find relevant posts in the database, e.g. users can filter for
categories.
If administrators summary of all tags and categories and how often they are
used, they learn what new category might be convenient for users, e.g. by
looking at the popularity of a tag.
Background:
Given we have a selection of tags and categories as well as posts
And my user account has the role "administrator"
Given I am logged in
Scenario: See an overview of categories
When I navigate to the administration dashboard
And I click on "Categories"
Then I can see a list of categories ordered by post count:
| Icon | Name | Post Count |
| | Just For Fun | 5 |
| | Happyness & Values | 2 |
| | Health & Wellbeing | 1 |
Scenario: See an overview of tags
When I navigate to the administration dashboard
And I click on "Tags"
Then I can see a list of tags ordered by user and post count:
| # | Name | Nutzer | Beiträge |
| 1 | Naturschutz | 2 | 2 |
| 2 | Freiheit | 2 | 2 |
| 3 | Umwelt | 1 | 1 |
| 4 | Demokratie | 1 | 1 |

View File

@ -0,0 +1,112 @@
import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps'
const baseUrl = 'http://localhost:3000'
const username = 'Peter Lustig'
const login = (email, password) => {
cy.visit(`${baseUrl}/login`)
cy.get('input[name=email]')
.trigger('focus')
.type(email)
cy.get('input[name=password]')
.trigger('focus')
.type(password)
cy.get('button[name=submit]')
.as('submitButton')
.click()
cy.location('pathname').should('eq', '/') // we're in!
}
const logout = () => {
cy.visit(`${baseUrl}/logout`)
cy.location('pathname').should('contain', '/login') // we're out
}
Given('I am logged in', () => {
login('admin@example.org', 1234)
})
Given('we have a selection of tags and categories as well as posts', () => {
// TODO: use db factories instead of seed data
})
Given('my account has the following details:', (table) => {
// TODO: use db factories instead of seed data
})
Given('my user account has the role {string}', (role) => {
// TODO: use db factories instead of seed data
})
When('I log out', logout)
When(`I visit the {string} page`, route => {
cy.visit(`${baseUrl}/${route}`)
})
When('I fill in my email and password combination and click submit', () => {
login('admin@example.org', 1234)
})
When('I refresh the page', () => {
cy.reload()
})
When('I log out through the menu in the top right corner', () => {
cy.get('.avatar-menu').click()
cy.get('.avatar-menu-popover')
.find('a')
.contains('Logout')
.click()
})
Then('I can click on my profile picture in the top right corner', () => {
cy.get('.avatar-menu').click()
})
Then('I can see my name {string} in the dropdown menu', () => {
cy.get('.avatar-menu-popover').should('contain', username)
})
Then('I see the login screen again', () => {
cy.location('pathname').should('contain', '/login')
cy.contains(
'Wenn Du ein Konto bei Human Connection hast, melde Dich bitte hier an.'
)
})
Then('I am still logged in', () => {
cy.get('.avatar-menu').click()
cy.get('.avatar-menu-popover').contains(username)
})
When('I navigate to the administration dashboard', () => {
cy.get('.avatar-menu').click()
cy.get('a').contains('Systemverwaltung').click()
})
When(`I click on {string}`, (linkOrButton) => {
cy.contains(linkOrButton).click()
})
Then('I can see a list of categories ordered by post count:', (table) => {
// TODO: match the table in the feature with the html table
cy.get('thead').find('tr th').should('have.length', 3)
const last_column = cy.get('tbody').find('tr td:last-child').then((last_column) => {
cy.wrap(last_column)
const values = last_column.map((i, td) => parseInt(td.textContent)).toArray()
const ordered_descending = values.slice(0).sort((a,b) => b - a)
return cy.wrap(values).should('deep.eq', ordered_descending)
})
})
Then('I can see a list of tags ordered by user and post count:', (table) => {
// TODO: match the table in the feature with the html table
cy.get('thead').find('tr th').should('have.length', 4)
const last_column = cy.get('tbody').find('tr td:last-child').then((last_column) => {
cy.wrap(last_column)
const values = last_column.map((i, td) => parseInt(td.textContent)).toArray()
const ordered_descending = values.slice(0).sort((a,b) => b - a)
return cy.wrap(values).should('deep.eq', ordered_descending)
})
})

View File

@ -1,73 +0,0 @@
/// <reference types="Cypress" />
const loginTestUser = function () {
// Visiting our app before each test removes any state build up from
cy.visit('http://localhost:3000/')
.get('.layout-blank')
.should('be.visible')
cy.location('pathname')
.should('contain', '/login')
cy.get('input[name=email]')
.as('inputEmail')
.should('be.empty')
.and('have.attr', 'placeholder', 'Deine E-Mail')
.trigger('focus')
.type('user@example.org')
cy.get('input[name=password]')
.as('inputPassword')
.should('be.empty')
// .and('have.attr', 'placeholder', 'Dein Passwort')
.trigger('focus')
.type('1234')
cy.get('button[name=submit]')
.as('submitButton')
.should('be.visible')
.and('not.be.disabled')
.click()
cy.get('@submitButton')
.should('be.disabled')
// .next('.snackbar')
cy.get('.layout-default')
cy.location('pathname')
.should('eq', '/')
}
const logout = function () {
cy.visit('http://localhost:3000/logout')
cy.location('pathname')
.should('contain', '/login')
cy.get('.layout-blank')
.should('be.visible')
}
context('Authentication', () => {
it('Login Testuser', loginTestUser)
it('Login & Logout', function () {
// login
loginTestUser()
// logout
logout()
})
it('Still logged in after page-reload', function () {
// login
loginTestUser()
cy.reload()
.get('.layout-default')
// logout
// logout()
})
})

View File

@ -11,7 +11,10 @@
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
module.exports = () => { // (on, config) => {
const cucumber = require('cypress-cucumber-preprocessor').default
module.exports = on => {
// (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
on('file:preprocessor', cucumber())
}

View File

@ -7,13 +7,6 @@ export default gql(`
name
avatar
createdAt
friendsCount
friends {
id
name
slug
avatar
}
badges {
id
key
@ -63,6 +56,11 @@ export default gql(`
deleted
image
createdAt
categories {
id
name
icon
}
author {
id
avatar

View File

@ -19,6 +19,7 @@
style="float: right"
>
<a
class="avatar-menu"
:href="$router.resolve({name: 'profile-slug', params: {slug: user.slug}}).href"
@click.prevent="toggleMenu()"
>
@ -30,6 +31,7 @@
</a>
<div
slot="popover"
class="avatar-menu-popover"
style="padding-top: .5rem; padding-bottom: .5rem;"
@mouseover="popoverMouseEnter"
@mouseleave="popoveMouseLeave"

View File

@ -15,6 +15,9 @@
"test": "jest",
"precommit": "yarn lint"
},
"cypress-cucumber-preprocessor": {
"nonGlobalStepDefinitions": true
},
"jest": {
"moduleFileExtensions": [
"js",
@ -29,7 +32,10 @@
"^@/(.*)$": "<rootDir>/src/$1",
"^@@/(.*)$": "<rootDir>/styleguide/src/system/$1",
"^~/(.*)$": "<rootDir>/$1"
}
},
"modulePathIgnorePatterns": [
"<rootDir>/styleguide"
]
},
"dependencies": {
"@nuxtjs/apollo": "^4.0.0-rc3",
@ -50,10 +56,12 @@
},
"devDependencies": {
"@vue/eslint-config-prettier": "^4.0.1",
"@vue/server-test-utils": "^1.0.0-beta.27",
"@vue/test-utils": "^1.0.0-beta.27",
"babel-eslint": "^10.0.1",
"babel-jest": "^23.6.0",
"babel-preset-env": "^1.7.0",
"cypress-cucumber-preprocessor": "^1.9.1",
"eslint": "^5.10.0",
"eslint-config-prettier": "^3.1.0",
"eslint-loader": "^2.0.0",

View File

@ -48,6 +48,10 @@ export default {
name: 'Categories',
path: `/admin/categories`
},
{
name: 'Tags',
path: `/admin/tags`
},
{
name: 'Settings',
path: `/admin/settings`

View File

@ -1,7 +1,46 @@
<template>
<ds-card>
<ds-space margin="small">
Categories...
</ds-space>
<ds-card space="small">
<ds-heading tag="h3">
Themen / Kategorien
</ds-heading>
<ds-table
:data="Category"
:fields="['icon', 'name', 'postCount']"
condensed
>
<template
slot="icon"
slot-scope="scope"
>
<ds-icon :name="scope.row.icon" />
</template>
</ds-table>
</ds-card>
</template>
<script>
import gql from 'graphql-tag'
export default {
data() {
return {
Category: []
}
},
apollo: {
Category: {
query: gql(`
query {
Category(orderBy: postCount_desc) {
id
name
slug
icon
postCount
}
}
`)
}
}
}
</script>

51
pages/admin/tags.vue Normal file
View File

@ -0,0 +1,51 @@
<template>
<ds-card space="small">
<ds-heading tag="h3">
Tags
</ds-heading>
<ds-table
:data="Tag"
:fields="fields"
condensed
>
<template
slot="id"
slot-scope="scope"
>
{{ scope.index + 1 }}
</template>
</ds-table>
</ds-card>
</template>
<script>
import gql from 'graphql-tag'
export default {
data() {
return {
Tag: [],
fields: {
id: { label: '#' },
name: { label: 'Name' },
taggedCountUnique: { label: 'Nutzer' },
taggedCount: { label: 'Beiträge' }
}
}
},
apollo: {
Tag: {
query: gql(`
query {
Tag(first: 20, orderBy: taggedCountUnique_desc) {
id
name
taggedCount
taggedCountUnique
}
}
`)
}
}
}
</script>

View File

@ -107,6 +107,7 @@ export default {
categories {
id
name
icon
}
shoutedCount
}

View File

@ -23,14 +23,22 @@
:post-id="post.id"
/>
<!-- Categories -->
<div class="tags">
<ds-icon
v-for="category in post.categories"
:key="category.id"
v-tooltip="{content: category.name, placement: 'top-start', delay: { show: 300 }}"
:name="category.icon"
size="large"
/>&nbsp;
<ds-space margin-bottom="small" />
<!--<div class="tags">
<ds-icon name="compass" /> <ds-tag
v-for="category in post.categories"
:key="category.id"
>
{{ category.name }}
</ds-tag>
</div>
</div>-->
<!-- Tags -->
<template v-if="post.tags && post.tags.length">
<ds-space margin="xx-small" />
@ -163,7 +171,7 @@ export default {
name
}
commentsCount
comments(orderBy: _id_desc) {
comments(orderBy: createdAt_desc) {
id
contentExcerpt
createdAt
@ -187,6 +195,7 @@ export default {
categories {
id
name
icon
}
shoutedCount
}

View File

@ -10,8 +10,9 @@
<ds-tag
v-for="category in post.categories"
:key="category.id"
v-tooltip="{content: category.name, placement: 'top-start', delay: { show: 300 }}"
>
{{ category.name }}
<ds-icon :name="category.icon" /> {{ category.name }}
</ds-tag>
</div>
<template v-if="post.tags && post.tags.length">
@ -81,6 +82,7 @@ export default {
categories {
id
name
icon
}
relatedContributions(first: 2) {
id
@ -89,6 +91,11 @@ export default {
contentExcerpt
shoutedCount
commentsCount
categories {
id
name
icon
}
author {
id
name

View File

@ -30,7 +30,7 @@
color="soft"
size="small"
>
Mitglied seid {{ user.createdAt | date('MMMM yyyy') }}
Mitglied seit {{ user.createdAt | date('MMMM yyyy') }}
</ds-text>
</ds-space>
<ds-space

View File

@ -15,7 +15,6 @@
color: $text-color-base;
background: $background-color-base;
border: $input-border-size solid $border-color-base;
border-radius: $border-radius-base;
outline: none;
@ -24,25 +23,21 @@
&::placeholder {
color: $text-color-disabled;
}
.ds-input-has-focus &,
&:focus {
border-color: $border-color-active;
background: $background-color-base;
}
.ds-input-is-disabled &,
&:disabled {
color: $text-color-disabled;
opacity: $opacity-disabled;
cursor: not-allowed;
}
.ds-input-has-error & {
border-color: $border-color-danger;
}
}
.ds-input-size-small {
font-size: $font-size-small;
@ -52,7 +47,6 @@
padding: $input-padding-vertical-small $space-x-small;
}
}
.ds-input-size-large {
font-size: $font-size-large;

1132
yarn.lock

File diff suppressed because it is too large Load Diff