Add global result frontend
Signed-off-by: Julius Härtl <jus@bitgrid.net>
This commit is contained in:
@@ -62,7 +62,7 @@ class CardCommentProvider implements IProvider {
|
|||||||
if (count($results) < $query->getLimit()) {
|
if (count($results) < $query->getLimit()) {
|
||||||
return SearchResult::complete(
|
return SearchResult::complete(
|
||||||
$this->l10n->t('Card comments'),
|
$this->l10n->t('Card comments'),
|
||||||
$results,
|
$results
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class DeckProvider implements IProvider {
|
|||||||
if (count($cardResults) < $query->getLimit()) {
|
if (count($cardResults) < $query->getLimit()) {
|
||||||
return SearchResult::complete(
|
return SearchResult::complete(
|
||||||
'Deck',
|
'Deck',
|
||||||
$results,
|
$results
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ use OCA\Deck\Db\CardMapper;
|
|||||||
use OCA\Deck\Search\CommentSearchResultEntry;
|
use OCA\Deck\Search\CommentSearchResultEntry;
|
||||||
use OCA\Deck\Search\FilterStringParser;
|
use OCA\Deck\Search\FilterStringParser;
|
||||||
use OCP\Comments\ICommentsManager;
|
use OCP\Comments\ICommentsManager;
|
||||||
|
use OCP\IL10N;
|
||||||
|
use OCP\IURLGenerator;
|
||||||
|
use OCP\IUserManager;
|
||||||
|
|
||||||
class SearchService {
|
class SearchService {
|
||||||
|
|
||||||
@@ -45,19 +48,31 @@ class SearchService {
|
|||||||
private $commentsManager;
|
private $commentsManager;
|
||||||
/** @var FilterStringParser */
|
/** @var FilterStringParser */
|
||||||
private $filterStringParser;
|
private $filterStringParser;
|
||||||
|
/** @var IUserManager */
|
||||||
|
private $userManager;
|
||||||
|
/** @var IL10N */
|
||||||
|
private $l10n;
|
||||||
|
/** @var IURLGenerator */
|
||||||
|
private $urlGenerator;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
BoardService $boardService,
|
BoardService $boardService,
|
||||||
CardMapper $cardMapper,
|
CardMapper $cardMapper,
|
||||||
CardService $cardService,
|
CardService $cardService,
|
||||||
ICommentsManager $commentsManager,
|
ICommentsManager $commentsManager,
|
||||||
FilterStringParser $filterStringParser
|
FilterStringParser $filterStringParser,
|
||||||
|
IUserManager $userManager,
|
||||||
|
IL10N $l10n,
|
||||||
|
IURLGenerator $urlGenerator
|
||||||
) {
|
) {
|
||||||
$this->boardService = $boardService;
|
$this->boardService = $boardService;
|
||||||
$this->cardMapper = $cardMapper;
|
$this->cardMapper = $cardMapper;
|
||||||
$this->cardService = $cardService;
|
$this->cardService = $cardService;
|
||||||
$this->commentsManager = $commentsManager;
|
$this->commentsManager = $commentsManager;
|
||||||
$this->filterStringParser = $filterStringParser;
|
$this->filterStringParser = $filterStringParser;
|
||||||
|
$this->userManager = $userManager;
|
||||||
|
$this->l10n = $l10n;
|
||||||
|
$this->urlGenerator = $urlGenerator;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function searchCards(string $term, int $limit = null, ?int $cursor = null): array {
|
public function searchCards(string $term, int $limit = null, ?int $cursor = null): array {
|
||||||
@@ -89,7 +104,7 @@ class SearchService {
|
|||||||
$matchedComments = $this->cardMapper->searchComments($boardIds, $this->filterStringParser->parse($term), $limit, $cursor);
|
$matchedComments = $this->cardMapper->searchComments($boardIds, $this->filterStringParser->parse($term), $limit, $cursor);
|
||||||
|
|
||||||
$self = $this;
|
$self = $this;
|
||||||
return array_filter(array_map(function ($cardRow) use ($self) {
|
return array_map(function ($cardRow) use ($self) {
|
||||||
$comment = $this->commentsManager->get($cardRow['comment_id']);
|
$comment = $this->commentsManager->get($cardRow['comment_id']);
|
||||||
unset($cardRow['comment_id']);
|
unset($cardRow['comment_id']);
|
||||||
$card = Card::fromRow($cardRow);
|
$card = Card::fromRow($cardRow);
|
||||||
@@ -97,6 +112,6 @@ class SearchService {
|
|||||||
$user = $this->userManager->get($comment->getActorId());
|
$user = $this->userManager->get($comment->getActorId());
|
||||||
$displayName = $user ? $user->getDisplayName() : '';
|
$displayName = $user ? $user->getDisplayName() : '';
|
||||||
return new CommentSearchResultEntry($comment->getId(), $comment->getMessage(), $displayName, $card, $this->urlGenerator, $this->l10n);
|
return new CommentSearchResultEntry($comment->getId(), $comment->getMessage(), $displayName, $card, $this->urlGenerator, $this->l10n);
|
||||||
}, $matchedComments));
|
}, $matchedComments);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
<p />
|
<p />
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
<GlobalSearchResults />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -75,10 +76,12 @@ import { mapState, mapGetters } from 'vuex'
|
|||||||
import Controls from '../Controls'
|
import Controls from '../Controls'
|
||||||
import Stack from './Stack'
|
import Stack from './Stack'
|
||||||
import { EmptyContent } from '@nextcloud/vue'
|
import { EmptyContent } from '@nextcloud/vue'
|
||||||
|
import GlobalSearchResults from '../search/GlobalSearchResults'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Board',
|
name: 'Board',
|
||||||
components: {
|
components: {
|
||||||
|
GlobalSearchResults,
|
||||||
Controls,
|
Controls,
|
||||||
Container,
|
Container,
|
||||||
Draggable,
|
Draggable,
|
||||||
@@ -178,13 +181,17 @@ export default {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
max-height: calc(100vh - 50px);
|
max-height: calc(100vh - 50px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board {
|
.board {
|
||||||
padding-left: $board-spacing;
|
padding-left: $board-spacing;
|
||||||
position: relative;
|
position: relative;
|
||||||
height: calc(100% - 44px);
|
max-height: calc(100% - 44px);
|
||||||
overflow-x: scroll;
|
overflow: hidden;
|
||||||
|
overflow-x: auto;
|
||||||
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,12 +26,16 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AttachmentDragAndDrop v-if="card" :card-id="card.id" class="drop-upload--card">
|
<AttachmentDragAndDrop v-if="card" :card-id="card.id" class="drop-upload--card">
|
||||||
<div :class="{'compact': compactMode, 'current-card': currentCard, 'has-labels': card.labels && card.labels.length > 0, 'is-editing': editing, 'card__editable': canEdit}"
|
<div :class="{'compact': compactMode, 'current-card': currentCard, 'has-labels': card.labels && card.labels.length > 0, 'is-editing': editing, 'card__editable': canEdit, 'card__archived': card.archived }"
|
||||||
tag="div"
|
tag="div"
|
||||||
class="card"
|
class="card"
|
||||||
@click="openCard">
|
@click="openCard">
|
||||||
|
<div v-if="standalone" class="card-related">
|
||||||
|
<div :style="{backgroundColor: '#' + board.color}" class="board-bullet" />
|
||||||
|
{{ board.title }} » {{ stack.title }}
|
||||||
|
</div>
|
||||||
<div class="card-upper">
|
<div class="card-upper">
|
||||||
<h3 v-if="compactMode || isArchived || showArchived || !canEdit">
|
<h3 v-if="compactMode || isArchived || showArchived || !canEdit || standalone">
|
||||||
{{ card.title }}
|
{{ card.title }}
|
||||||
</h3>
|
</h3>
|
||||||
<h3 v-else-if="!editing">
|
<h3 v-else-if="!editing">
|
||||||
@@ -98,6 +102,10 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
standalone: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -114,6 +122,12 @@ export default {
|
|||||||
...mapGetters([
|
...mapGetters([
|
||||||
'isArchived',
|
'isArchived',
|
||||||
]),
|
]),
|
||||||
|
board() {
|
||||||
|
return this.$store.getters.boardById(this?.stack?.boardId)
|
||||||
|
},
|
||||||
|
stack() {
|
||||||
|
return this.$store.getters.stackById(this?.card?.stackId)
|
||||||
|
},
|
||||||
canEdit() {
|
canEdit() {
|
||||||
if (this.currentBoard) {
|
if (this.currentBoard) {
|
||||||
return !this.currentBoard.archived && this.$store.getters.canEdit
|
return !this.currentBoard.archived && this.$store.getters.canEdit
|
||||||
@@ -233,6 +247,9 @@ export default {
|
|||||||
&.card__editable .card-controls {
|
&.card__editable .card-controls {
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
&.card__archived {
|
||||||
|
background-color: var(--color-background-dark);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.duedate {
|
.duedate {
|
||||||
@@ -244,6 +261,24 @@ export default {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-related {
|
||||||
|
display: flex;
|
||||||
|
padding: 12px;
|
||||||
|
padding-bottom: 0px;
|
||||||
|
color: var(--color-text-maxcontrast);
|
||||||
|
|
||||||
|
.board-bullet {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: transparent;
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.compact {
|
.compact {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="card">
|
<div v-if="card">
|
||||||
<div @click.stop.prevent>
|
<div @click.stop.prevent>
|
||||||
<Actions v-if="canEdit && !isArchived">
|
<Actions>
|
||||||
<ActionButton v-if="showArchived === false && !isCurrentUserAssigned"
|
<ActionButton v-if="showArchived === false && !isCurrentUserAssigned"
|
||||||
icon="icon-user"
|
icon="icon-user"
|
||||||
:close-after-click="true"
|
:close-after-click="true"
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
{{ t('deck', 'Card details') }}
|
{{ t('deck', 'Card details') }}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
<ActionButton icon="icon-archive" :close-after-click="true" @click="archiveUnarchiveCard()">
|
<ActionButton icon="icon-archive" :close-after-click="true" @click="archiveUnarchiveCard()">
|
||||||
{{ showArchived ? t('deck', 'Unarchive card') : t('deck', 'Archive card') }}
|
{{ card.archived ? t('deck', 'Unarchive card') : t('deck', 'Archive card') }}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
<ActionButton v-if="showArchived === false"
|
<ActionButton v-if="showArchived === false"
|
||||||
icon="icon-delete"
|
icon="icon-delete"
|
||||||
|
|||||||
@@ -73,6 +73,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<GlobalSearchResults />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -82,6 +84,7 @@ import Controls from '../Controls'
|
|||||||
import CardItem from '../cards/CardItem'
|
import CardItem from '../cards/CardItem'
|
||||||
import { mapGetters } from 'vuex'
|
import { mapGetters } from 'vuex'
|
||||||
import moment from '@nextcloud/moment'
|
import moment from '@nextcloud/moment'
|
||||||
|
import GlobalSearchResults from '../search/GlobalSearchResults'
|
||||||
|
|
||||||
const FILTER_UPCOMING = 'upcoming'
|
const FILTER_UPCOMING = 'upcoming'
|
||||||
|
|
||||||
@@ -92,6 +95,7 @@ const SUPPORTED_FILTERS = [
|
|||||||
export default {
|
export default {
|
||||||
name: 'Overview',
|
name: 'Overview',
|
||||||
components: {
|
components: {
|
||||||
|
GlobalSearchResults,
|
||||||
Controls,
|
Controls,
|
||||||
CardItem,
|
CardItem,
|
||||||
},
|
},
|
||||||
|
|||||||
199
src/components/search/GlobalSearchResults.vue
Normal file
199
src/components/search/GlobalSearchResults.vue
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
<!--
|
||||||
|
- @copyright Copyright (c) 2021 Julius Härtl <jus@bitgrid.net>
|
||||||
|
-
|
||||||
|
- @author Julius Härtl <jus@bitgrid.net>
|
||||||
|
-
|
||||||
|
- @license GNU AGPL version 3 or any later version
|
||||||
|
-
|
||||||
|
- This program is free software: you can redistribute it and/or modify
|
||||||
|
- it under the terms of the GNU Affero General Public License as
|
||||||
|
- published by the Free Software Foundation, either version 3 of the
|
||||||
|
- License, or (at your option) any later version.
|
||||||
|
-
|
||||||
|
- This program is distributed in the hope that it will be useful,
|
||||||
|
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
- GNU Affero General Public License for more details.
|
||||||
|
-
|
||||||
|
- You should have received a copy of the GNU Affero General Public License
|
||||||
|
- along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
-
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="searchQuery!==''" class="global-search">
|
||||||
|
<h2><RichText :text="t('deck', 'Search for {searchQuery} in all boards')" :arguments="queryStringArgs" /></h2>
|
||||||
|
<Actions>
|
||||||
|
<ActionButton icon="icon-close" @click="$store.commit('setSearchQuery', '')" />
|
||||||
|
</Actions>
|
||||||
|
<div class="search-wrapper">
|
||||||
|
<div v-if="loading || filteredResults.length > 0" class="search-results">
|
||||||
|
<CardItem v-for="card in filteredResults"
|
||||||
|
:id="card.id"
|
||||||
|
:key="card.id"
|
||||||
|
:standalone="true" />
|
||||||
|
<Placeholder v-if="loading" />
|
||||||
|
<InfiniteLoading :identifier="searchQuery" @infinite="infiniteHandler">
|
||||||
|
<div slot="spinner" />
|
||||||
|
<div slot="no-more" />
|
||||||
|
<div slot="no-results">
|
||||||
|
{{ t('deck', 'No results found') }}
|
||||||
|
</div>
|
||||||
|
</InfiniteLoading>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<p>{{ t('deck', 'No results found') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import CardItem from '../cards/CardItem'
|
||||||
|
import { mapState } from 'vuex'
|
||||||
|
import axios from '@nextcloud/axios'
|
||||||
|
import { generateOcsUrl } from '@nextcloud/router'
|
||||||
|
import InfiniteLoading from 'vue-infinite-loading'
|
||||||
|
import RichText from '@juliushaertl/vue-richtext'
|
||||||
|
import Placeholder from './Placeholder'
|
||||||
|
import { Actions, ActionButton } from '@nextcloud/vue'
|
||||||
|
|
||||||
|
const createCancelToken = () => axios.CancelToken.source()
|
||||||
|
|
||||||
|
function search({ query, cursor }) {
|
||||||
|
const cancelToken = createCancelToken()
|
||||||
|
|
||||||
|
const request = async() => axios.get(generateOcsUrl('apps/deck/api/v1.0', 2) + '/search', {
|
||||||
|
cancelToken: cancelToken.token,
|
||||||
|
params: {
|
||||||
|
term: query,
|
||||||
|
limit: 20,
|
||||||
|
cursor,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
request,
|
||||||
|
cancel: cancelToken.cancel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'GlobalSearchResults',
|
||||||
|
components: { CardItem, InfiniteLoading, RichText, Placeholder, Actions, ActionButton },
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
results: [],
|
||||||
|
cancel: null,
|
||||||
|
loading: false,
|
||||||
|
cursor: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapState({
|
||||||
|
searchQuery: state => state.searchQuery,
|
||||||
|
}),
|
||||||
|
filteredResults() {
|
||||||
|
const sortFn = (a, b) => a.archived - b.archived || b.lastModified - a.lastModified
|
||||||
|
if (this.$route.params.id) {
|
||||||
|
return this.results.filter((result) => result.relatedBoard.id.toString() !== this.$route.params.id.toString()).sort(sortFn)
|
||||||
|
}
|
||||||
|
return [...this.results].sort(sortFn)
|
||||||
|
},
|
||||||
|
queryStringArgs() {
|
||||||
|
return {
|
||||||
|
searchQuery: this.searchQuery,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
searchQuery() {
|
||||||
|
this.cursor = null
|
||||||
|
this.loading = true
|
||||||
|
this.search()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
infiniteHandler($state) {
|
||||||
|
this.loading = true
|
||||||
|
this.search().then((data) => {
|
||||||
|
if (data.length) {
|
||||||
|
$state.loaded()
|
||||||
|
} else {
|
||||||
|
$state.complete()
|
||||||
|
}
|
||||||
|
this.loading = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async search() {
|
||||||
|
if (this.cancel) {
|
||||||
|
this.cancel()
|
||||||
|
}
|
||||||
|
const { request, cancel } = await search({ query: this.searchQuery, cursor: this.cursor })
|
||||||
|
this.cancel = cancel
|
||||||
|
const { data } = await request()
|
||||||
|
|
||||||
|
if (this.cursor === null) {
|
||||||
|
this.results = []
|
||||||
|
}
|
||||||
|
if (data.ocs.data.length > 0) {
|
||||||
|
data.ocs.data.forEach((card) => {
|
||||||
|
this.$store.dispatch('addCardData', card)
|
||||||
|
})
|
||||||
|
this.results = [...this.results, ...data.ocs.data]
|
||||||
|
this.cursor = data.ocs.data[data.ocs.data.length - 1].lastModified
|
||||||
|
}
|
||||||
|
return data.ocs.data
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '../../css/variables.scss';
|
||||||
|
|
||||||
|
.global-search {
|
||||||
|
width: 100%;
|
||||||
|
padding: $board-spacing + $stack-spacing;
|
||||||
|
padding-bottom: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 35vh;
|
||||||
|
max-height: 50vh;
|
||||||
|
flex-shrink: 1;
|
||||||
|
flex-grow: 1;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
z-index: 1010;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.action-item.icon-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
}
|
||||||
|
.search-wrapper {
|
||||||
|
overflow: scroll;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2::v-deep span {
|
||||||
|
background-color: var(--color-background-dark);
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
flex-grow: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&::v-deep .card {
|
||||||
|
width: $stack-width;
|
||||||
|
margin-right: $stack-spacing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
115
src/components/search/Placeholder.vue
Normal file
115
src/components/search/Placeholder.vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card--placeholder">
|
||||||
|
<svg class="card-placeholder__gradient">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="card-placeholder__gradient">
|
||||||
|
<stop offset="0%" :stop-color="light">
|
||||||
|
<animate attributeName="stop-color"
|
||||||
|
:values="`${light}; ${light}; ${dark}; ${dark}; ${light}`"
|
||||||
|
dur="2s"
|
||||||
|
repeatCount="indefinite" />
|
||||||
|
</stop>
|
||||||
|
<stop offset="100%" :stop-color="dark">
|
||||||
|
<animate attributeName="stop-color"
|
||||||
|
:values="`${dark}; ${light}; ${light}; ${dark}; ${dark}`"
|
||||||
|
dur="2s"
|
||||||
|
repeatCount="indefinite" />
|
||||||
|
</stop>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
class="card-placeholder__placeholder"
|
||||||
|
:class="{ 'standalone': standalone }"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="url(#card-placeholder__gradient)">
|
||||||
|
<rect class="card-placeholder__placeholder-line-header" :style="{width: `calc(${randWidth()}%)`}" />
|
||||||
|
<rect class="card-placeholder__placeholder-line-one" />
|
||||||
|
<rect class="card-placeholder__placeholder-line-two" :style="{width: `calc(${randWidth()}%)`}" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'Placeholder',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
light: null,
|
||||||
|
dark: null,
|
||||||
|
standalone: true,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
const styles = getComputedStyle(document.documentElement)
|
||||||
|
this.dark = styles.getPropertyValue('--color-placeholder-dark')
|
||||||
|
this.light = styles.getPropertyValue('--color-placeholder-light')
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
randWidth() {
|
||||||
|
return Math.floor(Math.random() * 20) + 40
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '../../css/variables.scss';
|
||||||
|
$clickable-area: 44px;
|
||||||
|
|
||||||
|
.card--placeholder {
|
||||||
|
width: $stack-width;
|
||||||
|
margin-right: $stack-spacing;
|
||||||
|
padding: $card-padding;
|
||||||
|
transition: box-shadow 0.1s ease-in-out;
|
||||||
|
box-shadow: 0 0 2px 0 var(--color-box-shadow);
|
||||||
|
border-radius: var(--border-radius-large);
|
||||||
|
font-size: 100%;
|
||||||
|
margin-bottom: $card-spacing;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-placeholder__gradient {
|
||||||
|
position: fixed;
|
||||||
|
height: 0;
|
||||||
|
width: 0;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-placeholder__placeholder {
|
||||||
|
width: 100%;
|
||||||
|
&-line-header,
|
||||||
|
&-line-one,
|
||||||
|
&-line-two {
|
||||||
|
width: 100%;
|
||||||
|
height: 1em;
|
||||||
|
x: 0;
|
||||||
|
}
|
||||||
|
&-line-header {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
&-line-one {
|
||||||
|
y: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-line-two {
|
||||||
|
y: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.standalone {
|
||||||
|
.card-placeholder__placeholder-line-header {
|
||||||
|
visibility: visible;
|
||||||
|
y: 5px;
|
||||||
|
}
|
||||||
|
.card-placeholder__placeholder-line-one {
|
||||||
|
y: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-placeholder__placeholder-line-two {
|
||||||
|
y: 60px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { CardApi } from './../services/CardApi'
|
import { CardApi } from './../services/CardApi'
|
||||||
|
import moment from 'moment'
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
|
|
||||||
const apiClient = new CardApi()
|
const apiClient = new CardApi()
|
||||||
@@ -86,8 +87,90 @@ export default {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return card.title.toLowerCase().includes(getters.getSearchQuery.toLowerCase())
|
let hasMatch = true
|
||||||
|| card.description.toLowerCase().includes(getters.getSearchQuery.toLowerCase())
|
const matches = getters.getSearchQuery.match(/(?:[^\s"]+|"[^"]*")+/g)
|
||||||
|
|
||||||
|
const filterOutQuotes = (q) => {
|
||||||
|
if (q[0] === '"' && q[q.length - 1] === '"') {
|
||||||
|
return q.substr(1, -1)
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
for (const match of matches) {
|
||||||
|
let [filter, query] = match.indexOf(':') !== -1 ? match.split(/:(.+)/) : [null, match]
|
||||||
|
|
||||||
|
if (filter === 'title') {
|
||||||
|
hasMatch = hasMatch && card.title.toLowerCase().includes(filterOutQuotes(query).toLowerCase())
|
||||||
|
} else if (filter === 'description') {
|
||||||
|
hasMatch = hasMatch && card.description.toLowerCase().includes(filterOutQuotes(query).toLowerCase())
|
||||||
|
} else if (filter === 'list') {
|
||||||
|
const stack = this.getters.stackById(card.stackId)
|
||||||
|
if (!stack) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hasMatch = hasMatch && stack.title.toLowerCase().includes(filterOutQuotes(query).toLowerCase())
|
||||||
|
} else if (filter === 'tag') {
|
||||||
|
hasMatch = hasMatch && card.labels.findIndex((label) => label.title.toLowerCase().includes(filterOutQuotes(query).toLowerCase())) !== -1
|
||||||
|
} else if (filter === 'date') {
|
||||||
|
const datediffHour = ((new Date(card.duedate) - new Date()) / 3600 / 1000)
|
||||||
|
query = filterOutQuotes(query)
|
||||||
|
switch (query) {
|
||||||
|
case 'overdue':
|
||||||
|
hasMatch = hasMatch && (card.overdue === 3)
|
||||||
|
break
|
||||||
|
case 'today':
|
||||||
|
hasMatch = hasMatch && (datediffHour > 0 && datediffHour <= 24 && card.duedate !== null)
|
||||||
|
break
|
||||||
|
case 'week':
|
||||||
|
hasMatch = hasMatch && (datediffHour > 0 && datediffHour <= 7 * 24 && card.duedate !== null)
|
||||||
|
break
|
||||||
|
case 'month':
|
||||||
|
hasMatch = hasMatch && (datediffHour > 0 && datediffHour <= 30 * 24 && card.duedate !== null)
|
||||||
|
break
|
||||||
|
case 'none':
|
||||||
|
hasMatch = hasMatch && (card.duedate === null)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card.duedate === null || !hasMatch) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const comparator = query[0] + (query[1] === '=' ? '=' : '')
|
||||||
|
const isValidComparator = ['<', '<=', '>', '>='].indexOf(comparator) !== -1
|
||||||
|
const parsedCardDate = moment(card.duedate)
|
||||||
|
const parsedDate = moment(query.substr(isValidComparator ? comparator.length : 0))
|
||||||
|
switch (comparator) {
|
||||||
|
case '<':
|
||||||
|
hasMatch = hasMatch && parsedCardDate.isBefore(parsedDate)
|
||||||
|
break
|
||||||
|
case '<=':
|
||||||
|
hasMatch = hasMatch && parsedCardDate.isSameOrBefore(parsedDate)
|
||||||
|
break
|
||||||
|
case '>':
|
||||||
|
hasMatch = hasMatch && parsedCardDate.isAfter(parsedDate)
|
||||||
|
break
|
||||||
|
case '>=':
|
||||||
|
hasMatch = hasMatch && parsedCardDate.isSameOrAfter(parsedDate)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
hasMatch = hasMatch && parsedCardDate.isSame(parsedDate)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (filter === 'assigned') {
|
||||||
|
hasMatch = hasMatch && card.assignedUsers.findIndex((assignment) => {
|
||||||
|
return assignment.participant.primaryKey.toLowerCase() === filterOutQuotes(query).toLowerCase()
|
||||||
|
|| assignment.participant.displayname.toLowerCase() === filterOutQuotes(query).toLowerCase()
|
||||||
|
}) !== -1
|
||||||
|
} else {
|
||||||
|
hasMatch = hasMatch && (card.title.toLowerCase().includes(filterOutQuotes(match).toLowerCase())
|
||||||
|
|| card.description.toLowerCase().includes(filterOutQuotes(match).toLowerCase()))
|
||||||
|
}
|
||||||
|
if (!hasMatch) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.order - b.order || a.createdAt - b.createdAt)
|
.sort((a, b) => a.order - b.order || a.createdAt - b.createdAt)
|
||||||
},
|
},
|
||||||
@@ -210,7 +293,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updatedCard = await apiClient[call](card)
|
const updatedCard = await apiClient[call](card)
|
||||||
commit('deleteCard', updatedCard)
|
commit('updateCard', updatedCard)
|
||||||
},
|
},
|
||||||
async assignCardToUser({ commit }, { card, assignee }) {
|
async assignCardToUser({ commit }, { card, assignee }) {
|
||||||
const user = await apiClient.assignUser(card.id, assignee.userId, assignee.type)
|
const user = await apiClient.assignUser(card.id, assignee.userId, assignee.type)
|
||||||
@@ -236,5 +319,14 @@ export default {
|
|||||||
const updatedCard = await apiClient.updateCard(card)
|
const updatedCard = await apiClient.updateCard(card)
|
||||||
commit('updateCardProperty', { property: 'duedate', card: updatedCard })
|
commit('updateCardProperty', { property: 'duedate', card: updatedCard })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addCardData({ commit }, cardData) {
|
||||||
|
const card = { ...cardData }
|
||||||
|
commit('addStack', card.relatedStack)
|
||||||
|
commit('addBoard', card.relatedBoard)
|
||||||
|
delete card.relatedStack
|
||||||
|
delete card.relatedBoard
|
||||||
|
commit('addCard', card)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ export default new Vuex.Store({
|
|||||||
boards: state => {
|
boards: state => {
|
||||||
return state.boards
|
return state.boards
|
||||||
},
|
},
|
||||||
|
boardById: state => (id) => {
|
||||||
|
return state.boards.find((board) => board.id === id)
|
||||||
|
},
|
||||||
assignables: state => {
|
assignables: state => {
|
||||||
return [
|
return [
|
||||||
...state.assignableUsers.map((user) => ({ ...user, type: 0 })),
|
...state.assignableUsers.map((user) => ({ ...user, type: 0 })),
|
||||||
|
|||||||
Reference in New Issue
Block a user