Merge branch 'master' into cypress-migrate-to-v10

This commit is contained in:
mahula 2023-03-17 15:17:27 +01:00
commit d87561f715
51 changed files with 648 additions and 318 deletions

View File

@ -293,84 +293,17 @@ jobs:
echo "BUILD_COMMIT=${GITHUB_SHA}" >> $GITHUB_ENV echo "BUILD_COMMIT=${GITHUB_SHA}" >> $GITHUB_ENV
- run: echo "BUILD_VERSION=${VERSION}-${GITHUB_RUN_NUMBER}" >> $GITHUB_ENV - run: echo "BUILD_VERSION=${VERSION}-${GITHUB_RUN_NUMBER}" >> $GITHUB_ENV
- name: Repository Dispatch - name: Repository Dispatch
uses: peter-evans/repository-dispatch@v1 uses: peter-evans/repository-dispatch@v2
with: with:
token: ${{ github.token }} token: ${{ github.token }}
event-type: trigger-build-success event-type: trigger-build-success
repository: ${{ github.repository }} repository: ${{ github.repository }}
client-payload: '{"ref": "${{ github.ref }}", "sha": "${{ github.sha }}", "VERSION": "${VERSION}", "BUILD_DATE": "${BUILD_DATE}", "BUILD_COMMIT": "${BUILD_COMMIT}", "BUILD_VERSION": "${BUILD_VERSION}"}' client-payload: '{"ref": "${{ github.ref }}", "sha": "${{ github.sha }}", "VERSION": "${VERSION}", "BUILD_DATE": "${BUILD_DATE}", "BUILD_COMMIT": "${BUILD_COMMIT}", "BUILD_VERSION": "${BUILD_VERSION}"}'
# ############################################################################## - name: Repository Dispatch stage.ocelot.social
# # JOB: KUBERNETES DEPLOY ACTUAL/LATEST VERSION ###################################### uses: peter-evans/repository-dispatch@v2
# ############################################################################## with:
# kubernetes_deploy: token: ${{ secrets.OCELOT_PUBLISH_EVENT_PAT }} # this token is required to access the other repository
# # see example https://github.com/do-community/example-doctl-action event-type: trigger-build-success
# # see example https://github.com/do-community/example-doctl-action/blob/main/.github/workflows/workflow.yaml repository: 'Ocelot-Social-Community/stage.ocelot.social'
# name: Kubernetes deploy of latest version to stage.ocelot.social cluster at DigitalOcean client-payload: '{"ref": "${{ github.ref }}", "sha": "${{ github.sha }}", "VERSION": "${VERSION}", "BUILD_DATE": "${BUILD_DATE}", "BUILD_COMMIT": "${BUILD_COMMIT}", "BUILD_VERSION": "${BUILD_VERSION}"}'
# runs-on: ubuntu-latest
# needs: [upload_to_dockerhub]
# steps:
# ##########################################################################
# # CHECKOUT CODE ##########################################################
# ##########################################################################
# - name: Checkout code
# uses: actions/checkout@v3
# ##########################################################################
# # SET ENVS ###############################################################
# ##########################################################################
# - name: ENV - VERSION
# run: echo "VERSION=$(node -p -e "require('./package.json').version")" >> $GITHUB_ENV
# - name: ENV - BUILD_VERSION
# run: echo "BUILD_VERSION=${VERSION}-${GITHUB_RUN_NUMBER}" >> $GITHUB_ENV
# ##########################################################################
# # Install DigitalOceans doctl and set kubeconfig #########################
# ##########################################################################
# - name: Install doctl
# uses: digitalocean/action-doctl@v2
# with:
# token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
# - name: Save DigitalOcean kubeconfig with short-lived credentials
# run: doctl kubernetes cluster kubeconfig save --expiry-seconds 600 cluster-stage-ocelot-social
# ##########################################################################
# # Deploy new Docker images to DigitalOcean Kubernetes cluster ############
# ##########################################################################
# # - name: Deploy 'latest' to DigitalOcean Kubernetes
# # run: |
# # kubectl -n default set image deployment/ocelot-webapp container-ocelot-webapp=ocelotsocialnetwork/webapp:latest
# # kubectl -n default rollout restart deployment/ocelot-webapp
# # kubectl -n default set image deployment/ocelot-backend container-ocelot-backend=ocelotsocialnetwork/backend:latest
# # kubectl -n default rollout restart deployment/ocelot-backend
# # kubectl -n default set image deployment/ocelot-maintenance container-ocelot-maintenance=ocelotsocialnetwork/maintenance:latest
# # kubectl -n default rollout restart deployment/ocelot-maintenance
# # kubectl -n default set image deployment/ocelot-neo4j container-ocelot-neo4j=ocelotsocialnetwork/neo4j-community:latest
# # kubectl -n default rollout restart deployment/ocelot-neo4j
# - name: Deploy actual version '$BUILD_VERSION' to DigitalOcean Kubernetes
# run: |
# kubectl -n default set image deployment/ocelot-webapp container-ocelot-webapp=ocelotsocialnetwork/webapp:$BUILD_VERSION
# kubectl -n default rollout restart deployment/ocelot-webapp
# kubectl -n default set image deployment/ocelot-backend container-ocelot-backend=ocelotsocialnetwork/backend:$BUILD_VERSION
# kubectl -n default rollout restart deployment/ocelot-backend
# kubectl -n default set image deployment/ocelot-maintenance container-ocelot-maintenance=ocelotsocialnetwork/maintenance:$BUILD_VERSION
# kubectl -n default rollout restart deployment/ocelot-maintenance
# kubectl -n default set image deployment/ocelot-neo4j container-ocelot-neo4j=ocelotsocialnetwork/neo4j-community:$BUILD_VERSION
# kubectl -n default rollout restart deployment/ocelot-neo4j
# # because this step 'kubectl -n default rollout status deployment/* --timeout=600s' does not work as expected
# # and we need the pods to be up again for cleaning and seeding the Neo4j database and the backend.
# # !!! this is not a perfect solution !!!
# # deployments are regularly up again after 3 minutes and 10 seconds
# - name: Sleep for 4 minutes, means 240 seconds
# run: sleep 240s
# shell: bash
# - name: Verify deployment and wait for the pods of each deployment to get ready for cleaning and seeding of the database
# run: |
# kubectl -n default rollout status deployment/ocelot-backend --timeout=600s
# kubectl -n default rollout status deployment/ocelot-neo4j --timeout=600s
# kubectl -n default rollout status deployment/ocelot-maintenance --timeout=600s
# kubectl -n default rollout status deployment/ocelot-webapp --timeout=600s
# - name: Run migrations for Neo4j database via backend for staging
# run: |
# kubectl -n default exec -it $(kubectl -n default get pods | grep ocelot-backend | awk '{ print $1 }') -- /bin/sh -c "yarn prod:migrate up"
# - name: Reset and seed Neo4j database via backend for staging
# # db cleaning and seeding is only possible in production if env 'PRODUCTION_DB_CLEAN_ALLOW=true' is set in deployment
# run: |
# kubectl -n default exec -it $(kubectl -n default get pods | grep ocelot-backend | awk '{ print $1 }') -- /bin/sh -c "node --experimental-repl-await dist/db/clean.js && node --experimental-repl-await dist/db/seed.js"

View File

@ -329,19 +329,16 @@ jobs:
- name: backend | docker-compose - name: backend | docker-compose
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp neo4j backend run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp neo4j backend
- name: cypress | Fullstack tests - name: cypress | Fullstack tests
id: e2e-tests
run: | run: |
yarn install yarn install
yarn run cypress:run --spec $(cypress/parallel-features.sh ${{ matrix.job }} ${{ env.jobs }} ) yarn run cypress:run --spec $(cypress/parallel-features.sh ${{ matrix.job }} ${{ env.jobs }} )
########################################################################## ##########################################################################
# UPLOAD SCREENSHOTS & VIDEO ############################################# # UPLOAD SCREENSHOTS - IF TESTS FAIL #####################################
########################################################################## ##########################################################################
- name: Upload Artifact - name: Full stack tests | if any test failed, upload screenshots
if: ${{ failure() && steps.e2e-tests.conclusion == 'failure' }}
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: cypress-screenshots name: cypress-screenshots
path: cypress/screenshots/ path: cypress/screenshots/
- name: Upload Artifact
uses: actions/upload-artifact@v3
with:
name: cypress-videos
path: cypress/videos/

View File

@ -197,7 +197,8 @@ Prepare database once before you start by running the following command in a sec
```bash ```bash
# in main folder while docker-compose is up # in main folder while docker-compose is up
$ docker-compose exec backend yarn run db:migrate init $ docker compose exec backend yarn run db:migrate init
$ docker compose exec backend yarn run db:migrate up
``` ```
Then clear and seed database by running the following command as well in the second terminal: Then clear and seed database by running the following command as well in the second terminal:

View File

@ -81,8 +81,7 @@ More details about our GraphQL playground and how to use it with ocelot.social c
### Database Indexes and Constraints ### Database Indexes and Constraints
Database indexes and constraints need to be created when the database and the Database indexes and constraints need to be created and upgraded when the database and the backend are running:
backend is running:
{% tabs %} {% tabs %}
{% tab title="Docker" %} {% tab title="Docker" %}
@ -98,6 +97,11 @@ $ docker compose exec backend yarn prod:migrate init
$ docker compose exec backend /bin/sh -c "yarn prod:migrate init" $ docker compose exec backend /bin/sh -c "yarn prod:migrate init"
``` ```
```bash
# in main folder with docker compose running
$ docker exec backend yarn run db:migrate up
```
{% endtab %} {% endtab %}
{% tab title="Without Docker" %} {% tab title="Without Docker" %}
@ -107,6 +111,11 @@ $ docker compose exec backend /bin/sh -c "yarn prod:migrate init"
yarn run db:migrate init yarn run db:migrate init
``` ```
```bash
# in backend/ with database running (In docker or local)
yarn run db:migrate up
```
{% endtab %} {% endtab %}
{% endtabs %} {% endtabs %}
@ -134,6 +143,8 @@ $ docker exec backend yarn run db:reset
$ docker-compose down -v $ docker-compose down -v
# if container is not running, run this command to set up your database indexes and constraints # if container is not running, run this command to set up your database indexes and constraints
$ docker exec backend yarn run db:migrate init $ docker exec backend yarn run db:migrate init
# And then upgrade the indexes and const
$ docker exec backend yarn run db:migrate up
``` ```
{% endtab %} {% endtab %}

View File

@ -23,6 +23,7 @@ export const cleanDatabase = async (options = {}) => {
return transaction.run( return transaction.run(
` `
MATCH (everything) MATCH (everything)
WHERE NOT 'Migration' IN labels(everything)
DETACH DELETE everything DETACH DELETE everything
`, `,
) )

View File

@ -16,6 +16,7 @@ export default {
Group: async (_object, params, context, _resolveInfo) => { Group: async (_object, params, context, _resolveInfo) => {
const { isMember, id, slug, first, offset } = params const { isMember, id, slug, first, offset } = params
let pagination = '' let pagination = ''
const orderBy = 'ORDER BY group.createdAt DESC'
if (first !== undefined && offset !== undefined) pagination = `SKIP ${offset} LIMIT ${first}` if (first !== undefined && offset !== undefined) pagination = `SKIP ${offset} LIMIT ${first}`
const matchParams = { id, slug } const matchParams = { id, slug }
removeUndefinedNullValuesFromObject(matchParams) removeUndefinedNullValuesFromObject(matchParams)
@ -29,6 +30,7 @@ export default {
WITH group, membership WITH group, membership
WHERE (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner']) WHERE (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner'])
RETURN group {.*, myRole: membership.role} RETURN group {.*, myRole: membership.role}
${orderBy}
${pagination} ${pagination}
` `
} else { } else {
@ -39,6 +41,7 @@ export default {
WITH group WITH group
WHERE group.groupType IN ['public', 'closed'] WHERE group.groupType IN ['public', 'closed']
RETURN group {.*, myRole: NULL} RETURN group {.*, myRole: NULL}
${orderBy}
${pagination} ${pagination}
` `
} else { } else {
@ -48,6 +51,7 @@ export default {
WITH group, membership WITH group, membership
WHERE (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner']) WHERE (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner'])
RETURN group {.*, myRole: membership.role} RETURN group {.*, myRole: membership.role}
${orderBy}
${pagination} ${pagination}
` `
} }

View File

@ -1,46 +0,0 @@
const { defineConfig } = require("cypress");
const browserify = require("@badeball/cypress-cucumber-preprocessor/browserify");
const { addCucumberPreprocessorPlugin } = require("@badeball/cypress-cucumber-preprocessor");
// Test persistent(between commands) store
const testStore = {}
async function setupNodeEvents(on, config) {
await addCucumberPreprocessorPlugin(on, config);
on("file:preprocessor", browserify.default(config));
on("task", {
pushValue({ name, value }) {
testStore[name] = value
return true
},
getValue(name) {
console.log("getValue",name,testStore)
return testStore[name]
},
});
on("after:run", (results) => {
if (results) {
console.log(results.status);
}
});
return config;
}
module.exports = defineConfig({
e2e: {
projectId: "qa7fe2",
chromeWebSecurity: false,
baseUrl: "http://localhost:3000",
specPattern: "cypress/e2e/**/*.feature",
supportFile: "cypress/support/e2e.js",
retries: {
runMode: 2,
openMode: 0,
},
setupNodeEvents,
},
});

0
cypress/cypress.json Normal file
View File

View File

@ -0,0 +1,33 @@
# Docker
## Apple M1 Platform
***Attention:** For using Docker commands in Apple M1 environments!*
```bash
# set env variable for your shell
$ export DOCKER_DEFAULT_PLATFORM=linux/amd64
```
For even more informations, see [Docker More Closely](#docker-more-closely)
### Docker Compose Override File For Apple M1 Platform
For Docker compose `up` or `build` commands, you can use our Apple M1 override file that specifies the M1 platform:
```bash
# in main folder
# for production
$ docker compose -f docker-compose.yml -f docker-compose.apple-m1.override.yml up
# for production testing Docker images from DockerHub
$ docker compose -f docker-compose.ocelotsocial-branded.yml -f docker-compose.apple-m1.override.yml up
# only once: init admin user and create indexes and constraints in Neo4j database
$ docker compose exec backend /bin/sh -c "yarn prod:migrate init"
```
## Docker More Closely In Main Code
To get more informations about the Apple M1 platform and to analyze the Docker builds etc. you find our documentation in our main code, [here](https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/DOCKER_MORE_CLOSELY.md).

View File

@ -0,0 +1,12 @@
#!/bin/bash
# base setup
SCRIPT_PATH=$(realpath $0)
SCRIPT_DIR=$(dirname $SCRIPT_PATH)
# configuration
CONFIGURATION=${CONFIGURATION:-"example"}
KUBECONFIG=${KUBECONFIG:-${SCRIPT_DIR}/../configurations/${CONFIGURATION}/kubeconfig.yaml}
# clean & seed
kubectl --kubeconfig=${KUBECONFIG} -n default exec -it $(kubectl --kubeconfig=${KUBECONFIG} -n default get pods | grep ocelot-backend | awk '{ print $1 }') -- /bin/sh -c "node --experimental-repl-await dist/db/clean.js && node --experimental-repl-await dist/db/seed.js"

View File

@ -7,7 +7,13 @@ SCRIPT_DIR=$(dirname $SCRIPT_PATH)
# configuration # configuration
CONFIGURATION=${CONFIGURATION:-"example"} CONFIGURATION=${CONFIGURATION:-"example"}
KUBECONFIG=${KUBECONFIG:-${SCRIPT_DIR}/../configurations/${CONFIGURATION}/kubeconfig.yaml} KUBECONFIG=${KUBECONFIG:-${SCRIPT_DIR}/../configurations/${CONFIGURATION}/kubeconfig.yaml}
VALUES=${SCRIPT_DIR}/../configurations/${CONFIGURATION}/kubernetes/values.yaml VALUES=${SCRIPT_DIR}/../configurations/${CONFIGURATION}/kubernetes/values.
DOCKERHUB_OCELOT_TAG=${DOCKERHUB_OCELOT_TAG:-"latest"}
# upgrade with helm # upgrade with helm
helm --kubeconfig=${KUBECONFIG} upgrade ocelot --values ${VALUES} ${SCRIPT_DIR}/../src/kubernetes/ --debug --timeout 10m helm --kubeconfig=${KUBECONFIG} upgrade ocelot \
--values ${VALUES} \
--set appVersion="${DOCKERHUB_OCELOT_TAG}"
${SCRIPT_DIR}/../src/kubernetes/ \
--debug \
--timeout 10m

View File

@ -0,0 +1,14 @@
#!/bin/bash
# generate a secret and store it in the SECRET file.
# Note that this overwrites the existing file
# base setup
SCRIPT_PATH=$(realpath $0)
SCRIPT_DIR=$(dirname $SCRIPT_PATH)
# configuration
CONFIGURATION=${CONFIGURATION:-"example"}
SECRET_FILE=${SCRIPT_DIR}/../configurations/${CONFIGURATION}/SECRET
openssl rand -base64 32 > ${SECRET_FILE}

View File

@ -0,0 +1,44 @@
#!/bin/bash
# decrypt secrets in the selected configuration
# Note that existing decrypted files will be overwritten
# base setup
SCRIPT_PATH=$(realpath $0)
SCRIPT_DIR=$(dirname $SCRIPT_PATH)
# configuration
CONFIGURATION=${CONFIGURATION:-"example"}
SECRET=${SECRET}
SECRET_FILE=${SCRIPT_DIR}/../configurations/${CONFIGURATION}/SECRET
FILES=(\
"${SCRIPT_DIR}/../configurations/${CONFIGURATION}/.env" \
"${SCRIPT_DIR}/../configurations/${CONFIGURATION}/kubeconfig.yaml" \
"${SCRIPT_DIR}/../configurations/${CONFIGURATION}/kubernetes/values.yaml" \
"${SCRIPT_DIR}/../configurations/${CONFIGURATION}/kubernetes/dns.values.yaml" \
)
# Load SECRET from file if it is not set explicitly
if [ -z ${SECRET} ] && [ -f "${SECRET_FILE}" ]; then
SECRET=$(<${SECRET_FILE})
fi
# exit when there is no SECRET set
if [ -z ${SECRET} ]; then
echo "No SECRET provided and no SECRET-File found."
exit 1
fi
# decrypt
for file in "${FILES[@]}"
do
if [ -f "${file}.enc" ]; then
#gpg --symmetric --batch --passphrase="${SECRET}" --cipher-algo AES256 --output ${file}.enc ${file}
gpg --quiet --batch --yes --decrypt --passphrase="${SECRET}" --output ${file} ${file}.enc
echo "Decrypted ${file}"
fi
done
echo "DONE"
# gpg --quiet --batch --yes --decrypt --passphrase="${SECRET}" \
# --output $HOME/secrets/my_secret.json my_secret.json.gpg

View File

@ -0,0 +1,41 @@
#!/bin/bash
# encrypt secrets in the selected configuration
# Note that existing encrypted files will be overwritten
# base setup
SCRIPT_PATH=$(realpath $0)
SCRIPT_DIR=$(dirname $SCRIPT_PATH)
# configuration
CONFIGURATION=${CONFIGURATION:-"example"}
SECRET=${SECRET}
SECRET_FILE=${SCRIPT_DIR}/../configurations/${CONFIGURATION}/SECRET
FILES=(\
"${SCRIPT_DIR}/../configurations/${CONFIGURATION}/.env" \
"${SCRIPT_DIR}/../configurations/${CONFIGURATION}/kubeconfig.yaml" \
"${SCRIPT_DIR}/../configurations/${CONFIGURATION}/kubernetes/values.yaml" \
"${SCRIPT_DIR}/../configurations/${CONFIGURATION}/kubernetes/dns.values.yaml" \
)
# Load SECRET from file if it is not set explicitly
if [ -z ${SECRET} ] && [ -f "${SECRET_FILE}" ]; then
SECRET=$(<${SECRET_FILE})
fi
# exit when there is no SECRET set
if [ -z ${SECRET} ]; then
echo "No SECRET provided and no SECRET-File found."
exit 1
fi
# encrypt
for file in "${FILES[@]}"
do
if [ -f "${file}" ]; then
gpg --symmetric --batch --yes --passphrase="${SECRET}" --cipher-algo AES256 --output ${file}.enc ${file}
echo "Encrypted ${file}"
fi
done
echo "DONE"

View File

@ -1,3 +0,0 @@
/dns.values.yaml
/nginx.values.yaml
/values.yaml

View File

@ -44,7 +44,7 @@ for development, spin up a
[hosted Neo4j Sandbox instance](https://neo4j.com/download/), run Neo4j in one [hosted Neo4j Sandbox instance](https://neo4j.com/download/), run Neo4j in one
of the [many cloud options](https://neo4j.com/developer/guide-cloud-deployment/), of the [many cloud options](https://neo4j.com/developer/guide-cloud-deployment/),
[spin up Neo4j in a Docker container](https://neo4j.com/developer/docker/), [spin up Neo4j in a Docker container](https://neo4j.com/developer/docker/),
on Archlinux you can install [neo4j-community from AUR](https://aur.archlinux.org/packages/neo4j-community/) on Arch linux you can install [neo4j-community from AUR](https://aur.archlinux.org/packages/neo4j-community/)
or on Debian-based systems install [Neo4j from the Debian Repository](http://debian.neo4j.org/). or on Debian-based systems install [Neo4j from the Debian Repository](http://debian.neo4j.org/).
Just be sure to update the Neo4j connection string and credentials accordingly Just be sure to update the Neo4j connection string and credentials accordingly
in `backend/.env`. in `backend/.env`.

View File

@ -6,6 +6,7 @@
:icon="icon" :icon="icon"
:filled="isMember && !hovered" :filled="isMember && !hovered"
:danger="isMember && hovered" :danger="isMember && hovered"
v-tooltip="tooltip"
@mouseenter.native="onHover" @mouseenter.native="onHover"
@mouseleave.native="hovered = false" @mouseleave.native="hovered = false"
@click.prevent="toggle" @click.prevent="toggle"
@ -24,6 +25,7 @@ export default {
group: { type: Object, required: true }, group: { type: Object, required: true },
userId: { type: String, required: true }, userId: { type: String, required: true },
isMember: { type: Boolean, required: true }, isMember: { type: Boolean, required: true },
isNonePendingMember: { type: Boolean, required: true },
disabled: { type: Boolean, default: false }, disabled: { type: Boolean, default: false },
loading: { type: Boolean, default: false }, loading: { type: Boolean, default: false },
}, },
@ -35,17 +37,33 @@ export default {
}, },
computed: { computed: {
icon() { icon() {
if (this.isMember && this.hovered) { if (this.isMember) {
return 'close' if (this.isNonePendingMember) {
} else { return this.hovered ? 'close' : 'check'
return this.isMember ? 'check' : 'plus' } else {
return this.hovered ? 'close' : 'question-circle'
}
} }
return 'plus'
}, },
label() { label() {
if (this.isMember) { if (this.isMember) {
return this.$t('group.joinLeaveButton.iAmMember') if (this.isNonePendingMember) {
} else { return this.hovered
return this.$t('group.joinLeaveButton.join') ? this.$t('group.joinLeaveButton.leave')
: this.$t('group.joinLeaveButton.iAmMember')
} else {
return this.$t('group.joinLeaveButton.pendingMember')
}
}
return this.$t('group.joinLeaveButton.join')
},
tooltip() {
return {
content: this.$t('group.joinLeaveButton.tooltip'),
placement: 'right',
show: this.isMember && !this.isNonePendingMember && this.hovered,
trigger: this.isMember && !this.isNonePendingMember ? 'hover' : 'manual',
} }
}, },
}, },

View File

@ -30,6 +30,7 @@ export default {
} }
} }
.filterActive { .filterActive {
background-color: $color-success-active; color: $color-primary-inverse;
background-color: $color-primary-active;
} }
</style> </style>

View File

@ -41,7 +41,7 @@
{{ formData.title.length }}/{{ formSchema.title.max }} {{ formData.title.length }}/{{ formSchema.title.max }}
<base-icon v-if="errors && errors.title" name="warning" /> <base-icon v-if="errors && errors.title" name="warning" />
</ds-chip> </ds-chip>
<hc-editor <editor
:users="users" :users="users"
:value="formData.content" :value="formData.content"
:hashtags="hashtags" :hashtags="hashtags"
@ -64,14 +64,30 @@
{{ formData.categoryIds.length }} / 3 {{ formData.categoryIds.length }} / 3
<base-icon v-if="errors && errors.categoryIds" name="warning" /> <base-icon v-if="errors && errors.categoryIds" name="warning" />
</ds-chip> </ds-chip>
<div class="buttons"> <ds-flex class="buttons-footer" gutter="xxx-small">
<base-button data-test="cancel-button" :disabled="loading" @click="$router.back()" danger> <ds-flex-item width="3.5" style="margin-right: 16px; margin-bottom: 6px">
{{ $t('actions.cancel') }} <!-- eslint-disable vue/no-v-text-v-html-on-component -->
</base-button> <ds-text
<base-button type="submit" icon="check" :loading="loading" :disabled="errors" filled> v-if="showGroupHint"
{{ $t('actions.save') }} v-html="$t('contribution.visibleOnlyForMembersOfGroup', { name: groupName })"
</base-button> />
</div> <!-- eslint-enable vue/no-v-text-v-html-on-component -->
</ds-flex-item>
<ds-flex-item width="0.15" />
<ds-flex-item class="action-buttons-group" width="2">
<base-button
data-test="cancel-button"
:disabled="loading"
@click="$router.back()"
danger
>
{{ $t('actions.cancel') }}
</base-button>
<base-button type="submit" icon="check" :loading="loading" :disabled="errors" filled>
{{ $t('actions.save') }}
</base-button>
</ds-flex-item>
</ds-flex>
</base-card> </base-card>
</template> </template>
</ds-form> </ds-form>
@ -80,7 +96,7 @@
<script> <script>
import gql from 'graphql-tag' import gql from 'graphql-tag'
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import HcEditor from '~/components/Editor/Editor' import Editor from '~/components/Editor/Editor'
import PostMutations from '~/graphql/PostMutations.js' import PostMutations from '~/graphql/PostMutations.js'
import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect' import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
import ImageUploader from '~/components/Uploader/ImageUploader' import ImageUploader from '~/components/Uploader/ImageUploader'
@ -89,7 +105,7 @@ import PageParamsLink from '~/components/_new/features/PageParamsLink/PageParams
export default { export default {
components: { components: {
HcEditor, Editor,
ImageUploader, ImageUploader,
PageParamsLink, PageParamsLink,
CategoriesSelect, CategoriesSelect,
@ -99,8 +115,8 @@ export default {
type: Object, type: Object,
default: () => ({}), default: () => ({}),
}, },
groupId: { group: {
type: String, type: Object,
default: () => null, default: () => null,
}, },
}, },
@ -152,6 +168,15 @@ export default {
contentLength() { contentLength() {
return this.$filters.removeHtml(this.formData.content).length return this.$filters.removeHtml(this.formData.content).length
}, },
groupId() {
return this.group && this.group.id
},
showGroupHint() {
return this.groupId && ['closed', 'hidden'].includes(this.group.groupType)
},
groupName() {
return this.group && this.group.name
},
}, },
methods: { methods: {
submit() { submit() {
@ -284,9 +309,22 @@ export default {
align-self: flex-end; align-self: flex-end;
} }
> .buttons { > .buttons-footer {
justify-content: flex-end;
align-self: flex-end; align-self: flex-end;
width: 100%;
margin-top: $space-base; margin-top: $space-base;
> .action-buttons-group {
margin-left: auto;
display: flex;
justify-content: flex-end;
> button {
margin-left: 1em;
min-width: fit-content;
}
}
} }
.blur-toggle { .blur-toggle {
@ -297,5 +335,30 @@ export default {
display: block; display: block;
} }
} }
@media screen and (max-width: 656px) {
> .buttons-footer {
flex-direction: column;
margin-top: 5px;
> .action-buttons-group {
> button {
margin-left: 1em;
}
}
}
}
@media screen and (max-width: 280px) {
> .buttons-footer {
> .action-buttons-group {
flex-direction: column;
> button {
margin-bottom: 5px;
}
}
}
}
} }
</style> </style>

View File

@ -0,0 +1,57 @@
<template>
<span>
<base-button
class="my-filter-button my-filter-button-selected"
right
@click="clickButton"
filled
>
{{ title }}
</base-button>
<base-button
class="filter-remove"
@click="clickRemove"
icon="close"
:title="titleRemove"
size="small"
circle
filled
/>
</span>
</template>
<script>
export default {
name: 'HeaderButton',
props: {
title: {
type: String,
required: true,
},
clickButton: {
type: Function,
required: true,
},
titleRemove: {
type: String,
required: true,
},
clickRemove: {
type: Function,
required: true,
},
},
}
</script>
<style lang="scss">
.my-filter-button-selected {
padding-right: 30px;
margin-top: 4px;
}
.base-button.filter-remove {
position: relative;
margin-left: -31px;
top: -5px;
margin-right: 8px;
}
</style>

View File

@ -1,9 +1,14 @@
<template> <template>
<div class="group-list"> <ds-flex class="group-list">
<ds-space margin-bottom="small" v-for="group in groups" :key="group.id"> <ds-flex-item
v-for="group in groups"
:key="group.id"
:width="{ base: '98%', sm: '98%', md: '48%' }"
class="group-item"
>
<group-teaser :group="group" /> <group-teaser :group="group" />
</ds-space> </ds-flex-item>
</div> </ds-flex>
</template> </template>
<script> <script>
@ -19,3 +24,8 @@ export default {
}, },
} }
</script> </script>
<style lang="scss" scoped>
.group-item {
margin: 0 1% 2% 1%;
}
</style>

View File

@ -1,7 +1,16 @@
<template> <template>
<dropdown class="invite-button" offset="8" :placement="placement"> <dropdown class="invite-button" offset="8" :placement="placement">
<template #default="{ toggleMenu }"> <template #default="{ toggleMenu }">
<base-button icon="user-plus" circle ghost @click.prevent="toggleMenu" /> <base-button
icon="user-plus"
circle
ghost
v-tooltip="{
content: $t('invite-codes.button.tooltip'),
placement: 'bottom-start',
}"
@click.prevent="toggleMenu"
/>
</template> </template>
<template #popover> <template #popover>
<div class="invite-button-menu-popover"> <div class="invite-button-menu-popover">
@ -15,10 +24,7 @@
ghost ghost
@click="copyInviteLink" @click="copyInviteLink"
> >
<ds-text bold> <ds-text bold>{{ $t('invite-codes.copy-code') }}</ds-text>
{{ $t('invite-codes.copy-code') }}
{{ inviteCode.code }}
</ds-text>
</base-button> </base-button>
</base-card> </base-card>
</div> </div>
@ -108,6 +114,6 @@ export default {
} }
.invite-code { .invite-code {
left: 50%; margin-left: 25%;
} }
</style> </style>

View File

@ -30,7 +30,7 @@ export default {
/* dirty fix to override broken styleguide inline-styles */ /* dirty fix to override broken styleguide inline-styles */
.ds-grid { .ds-grid {
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)) !important; grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)) !important;
gap: 16px !important; gap: 32px 16px !important;
grid-auto-rows: 20px; grid-auto-rows: 20px;
} }

View File

@ -59,8 +59,8 @@ describe('NotificationsTable.vue', () => {
wrapper = Wrapper() wrapper = Wrapper()
}) })
it('renders a table', () => { it('renders a grid table', () => {
expect(wrapper.find('.ds-table').exists()).toBe(true) expect(wrapper.find('.notification-grid').exists()).toBe(true)
}) })
describe('renders 4 columns', () => { describe('renders 4 columns', () => {
@ -84,7 +84,7 @@ describe('NotificationsTable.vue', () => {
describe('Post', () => { describe('Post', () => {
let firstRowNotification let firstRowNotification
beforeEach(() => { beforeEach(() => {
firstRowNotification = wrapper.findAll('tbody tr').at(0) firstRowNotification = wrapper.findAll('.notification-grid-row').at(0)
}) })
it('renders the author', () => { it('renders the author', () => {
@ -117,7 +117,7 @@ describe('NotificationsTable.vue', () => {
describe('Comment', () => { describe('Comment', () => {
let secondRowNotification let secondRowNotification
beforeEach(() => { beforeEach(() => {
secondRowNotification = wrapper.findAll('tbody tr').at(1) secondRowNotification = wrapper.findAll('.notification-grid-row').at(1)
}) })
it('renders the author', () => { it('renders the author', () => {

View File

@ -1,62 +1,108 @@
<template> <template>
<ds-table v-if="notifications && notifications.length" :data="notifications" :fields="fields"> <div class="notification-grid" v-if="notifications && notifications.length">
<template #icon="scope"> <ds-grid>
<base-icon <ds-grid-item v-if="!isMobile" column-span="fullWidth">
v-if="scope.row.from.post" <ds-grid class="header-grid">
name="comment" <ds-grid-item v-for="field in fields" :key="field.label" class="ds-table-head-col">
v-tooltip="{ content: $t('notifications.comment'), placement: 'right' }" {{ field.label }}
/> </ds-grid-item>
<base-icon </ds-grid>
v-else </ds-grid-item>
name="bookmark" <ds-grid-item
v-tooltip="{ content: $t('notifications.post'), placement: 'right' }" v-for="notification in notifications"
/> :key="notification.id"
</template> column-span="fullWidth"
<template #user="scope"> class="notification-grid-row"
<ds-space margin-bottom="base">
<client-only>
<user-teaser
:user="scope.row.from.author"
:date-time="scope.row.from.createdAt"
:class="{ 'notification-status': scope.row.read }"
/>
</client-only>
</ds-space>
<ds-text :class="{ 'notification-status': scope.row.read, reason: true }">
{{ $t(`notifications.reason.${scope.row.reason}`) }}
</ds-text>
</template>
<template #post="scope">
<nuxt-link
class="notification-mention-post"
:class="{ 'notification-status': scope.row.read }"
:to="{
name: 'post-id-slug',
params: params(scope.row.from),
hash: hashParam(scope.row.from),
}"
@click.native="markNotificationAsRead(scope.row.from.id)"
> >
<b>{{ scope.row.from.title || scope.row.from.post.title | truncate(50) }}</b> <ds-grid>
</nuxt-link> <ds-grid-item>
</template> <ds-flex class="user-section">
<template #content="scope"> <ds-flex-item :width="{ base: '20%' }">
<b :class="{ 'notification-status': scope.row.read }"> <div>
{{ scope.row.from.contentExcerpt | removeHtml }} <base-card :wide-content="true">
</b> <base-icon
</template> v-if="notification.from.post"
</ds-table> name="comment"
v-tooltip="{ content: $t('notifications.comment'), placement: 'right' }"
/>
<base-icon
v-else
name="bookmark"
v-tooltip="{ content: $t('notifications.post'), placement: 'right' }"
/>
</base-card>
</div>
</ds-flex-item>
<ds-flex-item>
<div>
<base-card :wide-content="true">
<ds-space margin-bottom="base">
<client-only>
<user-teaser
:user="notification.from.author"
:date-time="notification.from.createdAt"
:class="{ 'notification-status': notification.read }"
/>
</client-only>
</ds-space>
<ds-text :class="{ 'notification-status': notification.read, reason: true }">
{{ $t(`notifications.reason.${notification.reason}`) }}
</ds-text>
</base-card>
</div>
</ds-flex-item>
</ds-flex>
</ds-grid-item>
<ds-grid-item>
<ds-flex class="content-section" :direction="{ base: 'column', xs: 'row' }">
<ds-flex-item>
<base-card :wide-content="true">
<nuxt-link
class="notification-mention-post"
:class="{ 'notification-status': notification.read }"
:to="{
name: 'post-id-slug',
params: params(notification.from),
hash: hashParam(notification.from),
}"
@click.native="markNotificationAsRead(notification.from.id)"
>
<b>
{{ notification.from.title || notification.from.post.title | truncate(50) }}
</b>
</nuxt-link>
</base-card>
</ds-flex-item>
<ds-flex-item>
<base-card :wide-content="true">
<b :class="{ 'notification-status': notification.read }">
{{ notification.from.contentExcerpt | removeHtml }}
</b>
</base-card>
</ds-flex-item>
</ds-flex>
</ds-grid-item>
</ds-grid>
</ds-grid-item>
</ds-grid>
</div>
<hc-empty v-else icon="alert" :message="$t('notifications.empty')" /> <hc-empty v-else icon="alert" :message="$t('notifications.empty')" />
</template> </template>
<script> <script>
import UserTeaser from '~/components/UserTeaser/UserTeaser' import UserTeaser from '~/components/UserTeaser/UserTeaser'
import HcEmpty from '~/components/Empty/Empty' import HcEmpty from '~/components/Empty/Empty'
import BaseCard from '../_new/generic/BaseCard/BaseCard.vue'
import mobile from '~/mixins/mobile'
const maxMobileWidth = 768 // at this point the table breaks down
export default { export default {
components: { components: {
UserTeaser, UserTeaser,
HcEmpty, HcEmpty,
BaseCard,
}, },
mixins: [mobile(maxMobileWidth)],
props: { props: {
notifications: { type: Array, default: () => [] }, notifications: { type: Array, default: () => [] },
}, },
@ -106,4 +152,39 @@ export default {
.notification-status { .notification-status {
opacity: $opacity-soft; opacity: $opacity-soft;
} }
/* fix to override flex-wrap style of ds flex component */
.notification-grid .content-section {
flex-wrap: nowrap;
}
.notification-grid .ds-grid.header-grid {
grid-template-columns: 1fr 4fr 3fr 3fr !important;
}
.notification-grid-row {
border-top: 1px dotted #e5e3e8;
}
.notification-grid .base-card {
border-radius: 0;
box-shadow: none;
padding: 16px 4px;
}
/* dirty fix to override broken styleguide inline-styles */
.notification-grid .ds-grid {
grid-template-columns: 5fr 6fr !important;
grid-auto-rows: auto !important;
grid-template-rows: 1fr;
gap: 0px !important;
}
@media screen and (max-width: 768px) {
.notification-grid .ds-grid {
grid-template-columns: 1fr !important;
}
.notification-grid .content-section {
border-top: 1px dotted #e5e3e8;
}
.notification-grid-row {
box-shadow: 0px 12px 26px -4px rgb(0 0 0 / 10%);
margin-top: 5px;
border-top: none;
}
}
</style> </style>

View File

@ -15,7 +15,13 @@
<img :src="post.image | proxyApiUrl" class="image" /> <img :src="post.image | proxyApiUrl" class="image" />
</template> </template>
<client-only> <client-only>
<user-teaser :user="post.author" :group="post.group" :date-time="post.createdAt" /> <div class="post-user-row">
<user-teaser :user="post.author" :group="post.group" :date-time="post.createdAt" />
<hc-ribbon
:class="[isPinned ? '--pinned' : '', post.image ? 'post-ribbon-w-img' : 'post-ribbon']"
:text="isPinned ? $t('post.pinned') : $t('post.name')"
/>
</div>
</client-only> </client-only>
<h2 class="title hyphenate-text">{{ post.title }}</h2> <h2 class="title hyphenate-text">{{ post.title }}</h2>
<!-- TODO: replace editor content with tiptap render view --> <!-- TODO: replace editor content with tiptap render view -->
@ -26,7 +32,7 @@
v-observe-visibility="(isVisible, entry) => visibilityChanged(isVisible, entry, post.id)" v-observe-visibility="(isVisible, entry) => visibilityChanged(isVisible, entry, post.id)"
> >
<div class="categories" v-if="categoriesActive"> <div class="categories" v-if="categoriesActive">
<hc-category <category
v-for="category in post.categories" v-for="category in post.categories"
:key="category.id" :key="category.id"
v-tooltip="{ v-tooltip="{
@ -42,7 +48,7 @@
</div> </div>
<div v-else class="categories-placeholder"></div> <div v-else class="categories-placeholder"></div>
<counter-icon <counter-icon
icon="bullhorn" icon="heart-o"
:count="post.shoutedCount" :count="post.shoutedCount"
:title="$t('contribution.amount-shouts', { amount: post.shoutedCount })" :title="$t('contribution.amount-shouts', { amount: post.shoutedCount })"
/> />
@ -73,19 +79,15 @@
</client-only> </client-only>
</footer> </footer>
</base-card> </base-card>
<hc-ribbon
:class="{ '--pinned': isPinned }"
:text="isPinned ? $t('post.pinned') : $t('post.name')"
/>
</nuxt-link> </nuxt-link>
</template> </template>
<script> <script>
import UserTeaser from '~/components/UserTeaser/UserTeaser' import Category from '~/components/Category'
import ContentMenu from '~/components/ContentMenu/ContentMenu' import ContentMenu from '~/components/ContentMenu/ContentMenu'
import HcRibbon from '~/components/Ribbon'
import HcCategory from '~/components/Category'
import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon' import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
import HcRibbon from '~/components/Ribbon'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import PostMutations from '~/graphql/PostMutations' import PostMutations from '~/graphql/PostMutations'
import { postMenuModalsData, deletePostMutation } from '~/components/utils/PostHelpers' import { postMenuModalsData, deletePostMutation } from '~/components/utils/PostHelpers'
@ -93,11 +95,11 @@ import { postMenuModalsData, deletePostMutation } from '~/components/utils/PostH
export default { export default {
name: 'PostTeaser', name: 'PostTeaser',
components: { components: {
UserTeaser, Category,
HcCategory,
HcRibbon,
ContentMenu, ContentMenu,
CounterIcon, CounterIcon,
HcRibbon,
UserTeaser,
}, },
props: { props: {
post: { post: {
@ -192,19 +194,38 @@ export default {
display: block; display: block;
height: 100%; height: 100%;
color: $text-color-base; color: $text-color-base;
}
> .ribbon { .post-user-row {
position: relative;
> .post-ribbon-w-img {
position: absolute; position: absolute;
top: 50%; // 14px (~height of ribbon element) + 24px(=margin of hero image)
right: -7px; top: -38px;
// 7px+24px(=padding of parent)
right: -31px;
}
> .post-ribbon {
position: absolute;
// 14px (~height of ribbon element) + 24px(=margin of hero image)
top: -24px;
// 7px(=offset)+24px(=margin of parent)
right: -31px;
} }
} }
.post-teaser > .base-card { .post-teaser > .base-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: visible;
height: 100%; height: 100%;
> .hero-image {
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
&.--blur-image > .hero-image > .image { &.--blur-image > .hero-image > .image {
filter: blur($blur-radius); filter: blur($blur-radius);
} }

View File

@ -4,7 +4,7 @@
:loading="loading" :loading="loading"
:disabled="disabled" :disabled="disabled"
:filled="shouted" :filled="shouted"
icon="bullhorn" icon="heart-o"
circle circle
@click="toggle" @click="toggle"
/> />

View File

@ -21,7 +21,7 @@ storiesOf('Generic/BaseButton', module)
template: ` template: `
<div> <div>
<base-button icon="edit">With Text</base-button> <base-button icon="edit">With Text</base-button>
<base-button icon="bullhorn" /> <base-button icon="heart-o" />
<base-button icon="trash" disabled /> <base-button icon="trash" disabled />
<base-button icon="trash" loading /> <base-button icon="trash" loading />
</div> </div>

View File

@ -74,7 +74,9 @@ describe('FollowList.vue', () => {
expect(wrapper.vm.allConnectionsCount).toBe(user.followingCount) expect(wrapper.vm.allConnectionsCount).toBe(user.followingCount)
expect(wrapper.findAll('.user-teaser')).toHaveLength(user.following.length) expect(wrapper.findAll('.user-teaser')).toHaveLength(user.following.length)
expect(wrapper.emitted('fetchAllConnections')).toEqual([['following']]) expect(wrapper.emitted('fetchAllConnections')).toEqual([
['following', user.followingCount],
])
}) })
}) })
@ -85,7 +87,9 @@ describe('FollowList.vue', () => {
expect(wrapper.vm.allConnectionsCount).toBe(user.followedByCount) expect(wrapper.vm.allConnectionsCount).toBe(user.followedByCount)
expect(wrapper.findAll('.user-teaser')).toHaveLength(user.followedBy.length) expect(wrapper.findAll('.user-teaser')).toHaveLength(user.followedBy.length)
expect(wrapper.emitted('fetchAllConnections')).toEqual([['followedBy']]) expect(wrapper.emitted('fetchAllConnections')).toEqual([
['followedBy', user.followedByCount],
])
}) })
}) })
}) })

View File

@ -6,7 +6,7 @@
:allProfilesCount="allConnectionsCount" :allProfilesCount="allConnectionsCount"
:profiles="connections" :profiles="connections"
:loading="loading" :loading="loading"
@fetchAllProfiles="$emit('fetchAllConnections', type)" @fetchAllProfiles="$emit('fetchAllConnections', type, allConnectionsCount)"
/> />
</template> </template>

View File

@ -4,7 +4,7 @@
<div class="metadata"> <div class="metadata">
<span class="counts"> <span class="counts">
<counter-icon icon="comments" :count="option.commentsCount" soft /> <counter-icon icon="comments" :count="option.commentsCount" soft />
<counter-icon icon="bullhorn" :count="option.shoutedCount" soft /> <counter-icon icon="heart-o" :count="option.shoutedCount" soft />
<counter-icon icon="hand-pointer" :count="option.clickedCount" soft /> <counter-icon icon="hand-pointer" :count="option.clickedCount" soft />
<counter-icon icon="eye" :count="option.viewedTeaserCount" soft /> <counter-icon icon="eye" :count="option.viewedTeaserCount" soft />
</span> </span>

View File

@ -289,7 +289,8 @@
}, },
"supportedFormats": "Füge ein Bild im Dateiformat JPG, PNG oder GIF ein" "supportedFormats": "Füge ein Bild im Dateiformat JPG, PNG oder GIF ein"
}, },
"title": "Titel" "title": "Titel",
"visibleOnlyForMembersOfGroup": "Dieser Beitrag wird nur für Mitglieder der Gruppe „<b>{name}</b>“ sichtbar sein."
}, },
"delete": { "delete": {
"cancel": "Abbrechen", "cancel": "Abbrechen",
@ -447,7 +448,10 @@
"in": "in", "in": "in",
"joinLeaveButton": { "joinLeaveButton": {
"iAmMember": "Bin Mitglied", "iAmMember": "Bin Mitglied",
"join": "Beitreten" "join": "Beitreten",
"leave": "Verlassen",
"pendingMember": "Ausstehendes Mitglied",
"tooltip": "Der Gruppeninhaber muss dich noch bestätigen."
}, },
"labelSlug": "Eindeutiger Gruppenname", "labelSlug": "Eindeutiger Gruppenname",
"leaveModal": { "leaveModal": {
@ -482,7 +486,7 @@
"admin": "Administrator", "admin": "Administrator",
"owner": "Inhaber", "owner": "Inhaber",
"pending": "Ausstehendes Mitglied", "pending": "Ausstehendes Mitglied",
"usual": "Einfaches Mitglied" "usual": "Mitglied"
}, },
"save": "Neue Gruppe anlegen", "save": "Neue Gruppe anlegen",
"type": "Öffentlichkeit der Gruppe", "type": "Öffentlichkeit der Gruppe",
@ -515,10 +519,13 @@
"no-results": "Keine Beiträge gefunden." "no-results": "Keine Beiträge gefunden."
}, },
"invite-codes": { "invite-codes": {
"copy-code": "Code:", "button": {
"tooltip": "Lade deine Freunde ein"
},
"copy-code": "Einladungslink kopieren",
"copy-success": "Einladungscode erfolgreich in die Zwischenablage kopiert", "copy-success": "Einladungscode erfolgreich in die Zwischenablage kopiert",
"not-available": "Du hast keinen Einladungscode zur Verfügung!", "not-available": "Du hast keinen Einladungscode zur Verfügung!",
"your-code": "Kopiere deinen Einladungscode in die Ablage:" "your-code": "Sende diesen Link per E-Mail oder in sozialen Medien, um deine Freunde einzuladen:"
}, },
"login": { "login": {
"email": "Deine E-Mail", "email": "Deine E-Mail",

View File

@ -289,7 +289,8 @@
}, },
"supportedFormats": "Insert a picture of file format JPG, PNG or GIF" "supportedFormats": "Insert a picture of file format JPG, PNG or GIF"
}, },
"title": "Title" "title": "Title",
"visibleOnlyForMembersOfGroup": "This post will only be visible to members of the “<b>{name}</b>” group."
}, },
"delete": { "delete": {
"cancel": "Cancel", "cancel": "Cancel",
@ -447,7 +448,10 @@
"in": "in", "in": "in",
"joinLeaveButton": { "joinLeaveButton": {
"iAmMember": "I'm a member", "iAmMember": "I'm a member",
"join": "Join" "join": "Join",
"leave": "Leave",
"pendingMember": "Pending member",
"tooltip": "The group owner has yet to confirm you."
}, },
"labelSlug": "Unique group name", "labelSlug": "Unique group name",
"leaveModal": { "leaveModal": {
@ -482,7 +486,7 @@
"admin": "Administrator", "admin": "Administrator",
"owner": "Owner", "owner": "Owner",
"pending": "Pending Member", "pending": "Pending Member",
"usual": "Simple Member" "usual": "Member"
}, },
"save": "Create new group", "save": "Create new group",
"type": "Visibility of the group", "type": "Visibility of the group",
@ -515,10 +519,13 @@
"no-results": "No contributions found." "no-results": "No contributions found."
}, },
"invite-codes": { "invite-codes": {
"copy-code": "Code:", "button": {
"tooltip": "Invite your friends"
},
"copy-code": "Copy Invite Link",
"copy-success": "Invite code copied to clipboard", "copy-success": "Invite code copied to clipboard",
"not-available": "You have no valid invite code available!", "not-available": "You have no valid invite code available!",
"your-code": "Copy your invite code to the clipboard:" "your-code": "Send this link per e-mail or in social media to invite your friends:"
}, },
"login": { "login": {
"email": "Your E-mail", "email": "Your E-mail",

View File

@ -302,7 +302,8 @@
"goal": null, "goal": null,
"joinLeaveButton": { "joinLeaveButton": {
"iAmMember": null, "iAmMember": null,
"join": null "join": null,
"pendingMember": null
}, },
"membersCount": null, "membersCount": null,
"membersListTitle": null "membersListTitle": null

View File

@ -291,7 +291,8 @@
"goal": null, "goal": null,
"joinLeaveButton": { "joinLeaveButton": {
"iAmMember": null, "iAmMember": null,
"join": null "join": null,
"pendingMember": null
}, },
"membersCount": null, "membersCount": null,
"membersListTitle": null "membersListTitle": null

View File

@ -299,7 +299,8 @@
"goal": null, "goal": null,
"joinLeaveButton": { "joinLeaveButton": {
"iAmMember": null, "iAmMember": null,
"join": null "join": null,
"pendingMember": null
}, },
"membersCount": null, "membersCount": null,
"membersListTitle": null "membersListTitle": null

View File

@ -87,7 +87,8 @@
"goal": null, "goal": null,
"joinLeaveButton": { "joinLeaveButton": {
"iAmMember": null, "iAmMember": null,
"join": null "join": null,
"pendingMember": null
}, },
"membersCount": null, "membersCount": null,
"membersListTitle": null "membersListTitle": null

View File

@ -171,7 +171,8 @@
"goal": null, "goal": null,
"joinLeaveButton": { "joinLeaveButton": {
"iAmMember": null, "iAmMember": null,
"join": null "join": null,
"pendingMember": null
}, },
"membersCount": null, "membersCount": null,
"membersListTitle": null "membersListTitle": null

View File

@ -337,7 +337,8 @@
"goal": null, "goal": null,
"joinLeaveButton": { "joinLeaveButton": {
"iAmMember": null, "iAmMember": null,
"join": null "join": null,
"pendingMember": null
}, },
"membersCount": null, "membersCount": null,
"membersListTitle": null "membersListTitle": null

View File

@ -316,7 +316,8 @@
"goal": null, "goal": null,
"joinLeaveButton": { "joinLeaveButton": {
"iAmMember": null, "iAmMember": null,
"join": null "join": null,
"pendingMember": null
}, },
"membersCount": null, "membersCount": null,
"membersListTitle": null "membersListTitle": null

View File

@ -15,7 +15,7 @@ export default {
}, },
{ {
name: 'viewport', name: 'viewport',
content: 'width=device-width, initial-scale=1', content: 'initial-scale=1',
}, },
{ {
hid: 'description', hid: 'description',

View File

@ -62,7 +62,7 @@ export default {
}, },
{ {
name: 'viewport', name: 'viewport',
content: 'width=device-width, initial-scale=1', content: 'initial-scale=1',
}, },
{ {
hid: 'description', hid: 'description',

View File

@ -102,6 +102,7 @@
:group="group || {}" :group="group || {}"
:userId="currentUser.id" :userId="currentUser.id"
:isMember="isGroupMember" :isMember="isGroupMember"
:isNonePendingMember="isGroupMemberNonePending"
:disabled="isGroupOwner" :disabled="isGroupOwner"
:loading="$apollo.loading" :loading="$apollo.loading"
@prepare="prepareJoinLeave" @prepare="prepareJoinLeave"

View File

@ -4,7 +4,7 @@
<tab-navigation :tabs="tabOptions" :activeTab="tabActive" @switch-tab="handleTab" /> <tab-navigation :tabs="tabOptions" :activeTab="tabActive" @switch-tab="handleTab" />
</ds-space> </ds-space>
<ds-space margin="large" /> <ds-space margin="large" />
<ds-container> <ds-space>
<!-- create group --> <!-- create group -->
<ds-space centered> <ds-space centered>
<nuxt-link :to="{ name: 'group-create' }"> <nuxt-link :to="{ name: 'group-create' }">
@ -49,7 +49,7 @@
@next="nextResults" @next="nextResults"
/> />
</ds-space> </ds-space>
</ds-container> </ds-space>
</div> </div>
</template> </template>

View File

@ -134,15 +134,15 @@ describe('PostIndex', () => {
}) })
describe('donation-info', () => { describe('donation-info', () => {
it('shows donation-info on default', () => { it('hides donation-info on default', () => {
wrapper = Wrapper() wrapper = Wrapper()
expect(wrapper.find('.top-info-bar').exists()).toBe(true) expect(wrapper.find('.top-info-bar').exists()).toBe(false)
}) })
it('hides donation-info if not "showDonations"', async () => { it('shows donation-info if "showDonations"', async () => {
wrapper = Wrapper() wrapper = Wrapper()
await wrapper.setData({ showDonations: false }) await wrapper.setData({ showDonations: true })
expect(wrapper.find('.top-info-bar').exists()).toBe(false) expect(wrapper.find('.top-info-bar').exists()).toBe(true)
}) })
}) })
}) })

View File

@ -43,47 +43,30 @@
&nbsp; &nbsp;
<base-icon class="my-filter-button" :name="filterButtonIcon"></base-icon> <base-icon class="my-filter-button" :name="filterButtonIcon"></base-icon>
</base-button> </base-button>
<span v-if="postsFilter['categories_some']">
<base-button class="my-filter-button" right @click="showFilter = !showFilter" filled>
{{ $t('contribution.filterMasonryGrid.myTopics') }}
</base-button>
<base-button
class="filter-remove"
@click="resetCategories"
icon="close"
:title="$t('filter-menu.deleteFilter')"
style="margin-left: -8px"
filled
/>
</span>
<span v-if="postsFilter['author']">
<base-button class="my-filter-button" right @click="showFilter = !showFilter" filled>
{{ $t('contribution.filterMasonryGrid.myFriends') }}
</base-button>
<base-button
class="filter-remove"
@click="resetByFollowed"
icon="close"
:title="$t('filter-menu.deleteFilter')"
style="margin-left: -8px"
filled
/>
</span>
<span v-if="postsFilter['postsInMyGroups']"> <header-button
<base-button class="my-filter-button" right @click="showFilter = !showFilter" filled> v-if="postsFilter['categories_some']"
{{ $t('contribution.filterMasonryGrid.myGroups') }} :title="$t('contribution.filterMasonryGrid.myTopics')"
</base-button> :clickButton="openFilterMenu"
<base-button :titleRemove="$t('filter-menu.deleteFilter')"
class="filter-remove" :clickRemove="resetCategories"
@click="resetByGroups" />
icon="close"
:title="$t('filter-menu.deleteFilter')"
style="margin-left: -8px"
filled
/>
</span>
<header-button
v-if="postsFilter['author']"
:title="$t('contribution.filterMasonryGrid.myFriends')"
:clickButton="openFilterMenu"
:titleRemove="$t('filter-menu.deleteFilter')"
:clickRemove="resetByFollowed"
/>
<header-button
v-if="postsFilter['postsInMyGroups']"
:title="$t('contribution.filterMasonryGrid.myGroups')"
:clickButton="openFilterMenu"
:titleRemove="$t('filter-menu.deleteFilter')"
:clickRemove="resetByGroups"
/>
<div id="my-filter" v-if="showFilter"> <div id="my-filter" v-if="showFilter">
<div @mouseleave="showFilter = false"> <div @mouseleave="showFilter = false">
<filter-menu-component @showFilterMenu="showFilterMenu" /> <filter-menu-component @showFilterMenu="showFilterMenu" />
@ -92,16 +75,16 @@
</div> </div>
</div> </div>
</ds-grid-item> </ds-grid-item>
<ds-space :margin-bottom="{ base: 'small', md: 'base', lg: 'large' }" /> <!-- Placeholder/Space Row -->
<ds-grid-item :row-span="1" column-span="fullWidth" />
<!-- hashtag filter -->
<ds-grid-item v-if="hashtag" :row-span="2" column-span="fullWidth"> <ds-grid-item v-if="hashtag" :row-span="2" column-span="fullWidth">
<hashtags-filter :hashtag="hashtag" @clearSearch="clearSearch" /> <hashtags-filter :hashtag="hashtag" @clearSearch="clearSearch" />
</ds-grid-item> </ds-grid-item>
<ds-space :margin-bottom="{ base: 'small', md: 'base', lg: 'large' }" />
<!-- donation info --> <!-- donation info -->
<ds-grid-item v-if="showDonations" class="top-info-bar" :row-span="1" column-span="fullWidth"> <ds-grid-item v-if="showDonations" class="top-info-bar" :row-span="1" column-span="fullWidth">
<donation-info :goal="goal" :progress="progress" /> <donation-info :goal="goal" :progress="progress" />
</ds-grid-item> </ds-grid-item>
<ds-space :margin-bottom="{ base: 'small', md: 'base', lg: 'large' }" />
<!-- news feed --> <!-- news feed -->
<template v-if="hasResults"> <template v-if="hasResults">
<masonry-grid-item <masonry-grid-item
@ -142,6 +125,7 @@ import HcEmpty from '~/components/Empty/Empty'
import PostTeaser from '~/components/PostTeaser/PostTeaser.vue' import PostTeaser from '~/components/PostTeaser/PostTeaser.vue'
import MasonryGrid from '~/components/MasonryGrid/MasonryGrid.vue' import MasonryGrid from '~/components/MasonryGrid/MasonryGrid.vue'
import MasonryGridItem from '~/components/MasonryGrid/MasonryGridItem.vue' import MasonryGridItem from '~/components/MasonryGrid/MasonryGridItem.vue'
import HeaderButton from '~/components/FilterMenu/HeaderButton'
import { mapGetters, mapMutations } from 'vuex' import { mapGetters, mapMutations } from 'vuex'
import { DonationsQuery } from '~/graphql/Donations' import { DonationsQuery } from '~/graphql/Donations'
import { filterPosts } from '~/graphql/PostQuery.js' import { filterPosts } from '~/graphql/PostQuery.js'
@ -159,6 +143,7 @@ export default {
MasonryGrid, MasonryGrid,
MasonryGridItem, MasonryGridItem,
FilterMenuComponent, FilterMenuComponent,
HeaderButton,
}, },
mixins: [postListActions], mixins: [postListActions],
data() { data() {
@ -167,7 +152,7 @@ export default {
hideByScroll: false, hideByScroll: false,
revScrollpos: 0, revScrollpos: 0,
showFilter: false, showFilter: false,
showDonations: true, showDonations: false,
goal: 15000, goal: 15000,
progress: 7000, progress: 7000,
posts: [], posts: [],
@ -225,6 +210,9 @@ export default {
resetCategories: 'posts/RESET_CATEGORIES', resetCategories: 'posts/RESET_CATEGORIES',
toggleCategory: 'posts/TOGGLE_CATEGORY', toggleCategory: 'posts/TOGGLE_CATEGORY',
}), }),
openFilterMenu() {
this.showFilter = !this.showFilter
},
showFilterMenu(e) { showFilterMenu(e) {
if (!e || (!e.target.closest('#my-filter') && !e.target.closest('.my-filter-button'))) { if (!e || (!e.target.closest('#my-filter') && !e.target.closest('.my-filter-button'))) {
if (!this.showFilter) return if (!this.showFilter) return
@ -354,13 +342,18 @@ export default {
align-items: center; align-items: center;
} }
.filterButtonMenu { .filterButtonMenu {
width: 95%;
position: fixed; position: fixed;
z-index: 6; z-index: 6;
margin-top: -35px; margin-top: -35px;
padding: 20px 10px 5px 10px; padding: 20px 10px 20px 10px;
border-radius: 7px;
background-color: #f5f4f6; background-color: #f5f4f6;
} }
@media screen and (max-width: 656px) {
.filterButtonMenu {
margin-top: -50px;
}
}
#my-filter { #my-filter {
background-color: white; background-color: white;
box-shadow: rgb(189 189 189) 1px 9px 15px 1px; box-shadow: rgb(189 189 189) 1px 9px 15px 1px;
@ -418,5 +411,8 @@ export default {
font-size: 23px; font-size: 23px;
z-index: 10; z-index: 10;
} }
.ds-grid {
padding-top: 1em;
}
} }
</style> </style>

View File

@ -9,7 +9,7 @@
<ds-space margin="large" /> <ds-space margin="large" />
<ds-flex :width="{ base: '100%' }" gutter="base"> <ds-flex :width="{ base: '100%' }" gutter="base">
<ds-flex-item :width="{ base: '100%', md: 5 }"> <ds-flex-item :width="{ base: '100%', md: 5 }">
<contribution-form :groupId="groupId" /> <contribution-form :group="group" />
</ds-flex-item> </ds-flex-item>
<ds-flex-item :width="{ base: '100%', md: 1 }">&nbsp;</ds-flex-item> <ds-flex-item :width="{ base: '100%', md: 1 }">&nbsp;</ds-flex-item>
</ds-flex> </ds-flex>

View File

@ -9,7 +9,10 @@
<ds-space margin="large" /> <ds-space margin="large" />
<ds-flex :width="{ base: '100%' }" gutter="base"> <ds-flex :width="{ base: '100%' }" gutter="base">
<ds-flex-item :width="{ base: '100%', md: 3 }"> <ds-flex-item :width="{ base: '100%', md: 3 }">
<contribution-form :contribution="contribution" /> <contribution-form
:contribution="contribution"
:group="contribution && contribution.group ? contribution.group : null"
/>
</ds-flex-item> </ds-flex-item>
<ds-flex-item :width="{ base: '100%', md: 1 }">&nbsp;</ds-flex-item> <ds-flex-item :width="{ base: '100%', md: 1 }">&nbsp;</ds-flex-item>
</ds-flex> </ds-flex>

View File

@ -384,9 +384,9 @@ export default {
this.user.followedByCurrentUser = followedByCurrentUser this.user.followedByCurrentUser = followedByCurrentUser
this.user.followedBy = followedBy this.user.followedBy = followedBy
}, },
fetchAllConnections(type) { fetchAllConnections(type, count) {
if (type === 'following') this.followingCount = Infinity if (type === 'following') this.followingCount = count
if (type === 'followedBy') this.followedByCount = Infinity if (type === 'followedBy') this.followedByCount = count
}, },
}, },
apollo: { apollo: {