feat: make taskList a composable
This commit is contained in:
parent
5a0c0eff9f
commit
281c922de1
6 changed files with 254 additions and 237 deletions
|
@ -191,7 +191,7 @@ import NamespaceService from '@/services/namespace'
|
|||
import EditLabels from '@/components/tasks/partials/editLabels.vue'
|
||||
|
||||
import {objectToSnakeCase} from '@/helpers/case'
|
||||
import {getDefaultParams} from '@/components/tasks/mixins/taskList'
|
||||
import {getDefaultParams} from '@/composables/taskList'
|
||||
|
||||
// FIXME: merge with DEFAULT_PARAMS in taskList.js
|
||||
const DEFAULT_PARAMS = {
|
||||
|
|
|
@ -1,101 +0,0 @@
|
|||
import TaskCollectionService from '@/services/taskCollection'
|
||||
|
||||
// FIXME: merge with DEFAULT_PARAMS in filters.vue
|
||||
export const getDefaultParams = () => ({
|
||||
sort_by: ['position', 'id'],
|
||||
order_by: ['asc', 'desc'],
|
||||
filter_by: ['done'],
|
||||
filter_value: ['false'],
|
||||
filter_comparator: ['equals'],
|
||||
filter_concat: 'and',
|
||||
})
|
||||
|
||||
/**
|
||||
* This mixin provides a base set of methods and properties to get tasks on a list.
|
||||
*/
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
taskCollectionService: new TaskCollectionService(),
|
||||
tasks: [],
|
||||
|
||||
currentPage: 0,
|
||||
|
||||
loadedList: null,
|
||||
|
||||
searchTerm: '',
|
||||
|
||||
showTaskFilter: false,
|
||||
params: {...getDefaultParams()},
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
// Only listen for query path changes
|
||||
'$route.query': {
|
||||
handler: 'loadTasksForPage',
|
||||
immediate: true,
|
||||
},
|
||||
'$route.path': 'loadTasksOnSavedFilter',
|
||||
},
|
||||
methods: {
|
||||
async loadTasks(
|
||||
page,
|
||||
search = '',
|
||||
params = null,
|
||||
forceLoading = false,
|
||||
) {
|
||||
// Because this function is triggered every time on topNavigation, we're putting a condition here to only load it when we actually want to show tasks
|
||||
// FIXME: This is a bit hacky -> Cleanup.
|
||||
if (
|
||||
this.$route.name !== 'list.list' &&
|
||||
this.$route.name !== 'list.table' &&
|
||||
!forceLoading
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (params === null) {
|
||||
params = this.params
|
||||
}
|
||||
|
||||
if (search !== '') {
|
||||
params.s = search
|
||||
}
|
||||
|
||||
const list = {listId: parseInt(this.$route.params.listId)}
|
||||
|
||||
const currentList = {
|
||||
id: list.listId,
|
||||
params,
|
||||
search,
|
||||
page,
|
||||
}
|
||||
if (JSON.stringify(currentList) === JSON.stringify(this.loadedList) && !forceLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
this.tasks = []
|
||||
this.tasks = await this.taskCollectionService.getAll(list, params, page)
|
||||
this.currentPage = page
|
||||
this.loadedList = JSON.parse(JSON.stringify(currentList))
|
||||
},
|
||||
|
||||
loadTasksForPage(e) {
|
||||
// The page parameter can be undefined, in the case where the user loads a new list from the side bar menu
|
||||
let page = Number(e.page)
|
||||
if (typeof e.page === 'undefined') {
|
||||
page = 1
|
||||
}
|
||||
let search = e.search
|
||||
if (typeof e.search === 'undefined') {
|
||||
search = ''
|
||||
}
|
||||
this.initTasks(page, search)
|
||||
},
|
||||
loadTasksOnSavedFilter() {
|
||||
if (typeof this.$route.params.listId !== 'undefined' && parseInt(this.$route.params.listId) < 0) {
|
||||
this.loadTasks(1, '', null, true)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
112
src/composables/taskList.js
Normal file
112
src/composables/taskList.js
Normal file
|
@ -0,0 +1,112 @@
|
|||
import { ref, watch, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import TaskCollectionService from '@/services/taskCollection'
|
||||
|
||||
// FIXME: merge with DEFAULT_PARAMS in filters.vue
|
||||
export const getDefaultParams = () => ({
|
||||
sort_by: ['position', 'id'],
|
||||
order_by: ['asc', 'desc'],
|
||||
filter_by: ['done'],
|
||||
filter_value: ['false'],
|
||||
filter_comparator: ['equals'],
|
||||
filter_concat: 'and',
|
||||
})
|
||||
|
||||
/**
|
||||
* This mixin provides a base set of methods and properties to get tasks on a list.
|
||||
*/
|
||||
export function createTaskList(initTasks) {
|
||||
const taskCollectionService = ref(new TaskCollectionService())
|
||||
const loading = computed(() => taskCollectionService.value.loading)
|
||||
const totalPages = computed(() => taskCollectionService.value.totalPages)
|
||||
|
||||
const tasks = ref([])
|
||||
const currentPage = ref(0)
|
||||
const loadedList = ref(null)
|
||||
const searchTerm = ref('')
|
||||
const showTaskFilter = ref(false)
|
||||
const params = ref({...getDefaultParams()})
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
async function loadTasks(
|
||||
page = 1,
|
||||
search = '',
|
||||
loadParams = { ...params.value },
|
||||
forceLoading = false,
|
||||
) {
|
||||
|
||||
// Because this function is triggered every time on topNavigation, we're putting a condition here to only load it when we actually want to show tasks
|
||||
// FIXME: This is a bit hacky -> Cleanup.
|
||||
if (
|
||||
route.name !== 'list.list' &&
|
||||
route.name !== 'list.table' &&
|
||||
!forceLoading
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (search !== '') {
|
||||
loadParams.s = search
|
||||
}
|
||||
|
||||
const list = {listId: parseInt(route.params.listId)}
|
||||
|
||||
const currentList = {
|
||||
id: list.listId,
|
||||
params: loadParams,
|
||||
search,
|
||||
page,
|
||||
}
|
||||
if (
|
||||
JSON.stringify(currentList) === JSON.stringify(loadedList.value) &&
|
||||
!forceLoading
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
tasks.value = []
|
||||
tasks.value = await taskCollectionService.value.getAll(list, loadParams, page)
|
||||
currentPage.value = page
|
||||
loadedList.value = JSON.parse(JSON.stringify(currentList))
|
||||
}
|
||||
|
||||
async function loadTasksForPage(query) {
|
||||
const { page, search } = query
|
||||
initTasks(params)
|
||||
await loadTasks(
|
||||
// The page parameter can be undefined, in the case where the user loads a new list from the side bar menu
|
||||
typeof page === 'undefined' ? 1 : Number(page),
|
||||
search,
|
||||
params.value,
|
||||
)
|
||||
}
|
||||
|
||||
async function loadTasksOnSavedFilter() {
|
||||
if (
|
||||
typeof route.params.listId !== 'undefined' &&
|
||||
parseInt(route.params.listId) < 0
|
||||
) {
|
||||
await loadTasks(1, '', null, true)
|
||||
}
|
||||
}
|
||||
|
||||
function initTaskList() {
|
||||
// Only listen for query path changes
|
||||
watch(() => route.query, loadTasksForPage, { immediate: true })
|
||||
watch(() => route.path, loadTasksOnSavedFilter)
|
||||
}
|
||||
|
||||
return {
|
||||
tasks,
|
||||
initTaskList,
|
||||
loading,
|
||||
totalPages,
|
||||
currentPage,
|
||||
showTaskFilter,
|
||||
loadTasks,
|
||||
searchTerm,
|
||||
params,
|
||||
}
|
||||
}
|
|
@ -8,28 +8,28 @@
|
|||
<router-link
|
||||
v-shortcut="'g l'"
|
||||
:title="$t('keyboardShortcuts.list.switchToListView')"
|
||||
:class="{'is-active': currentListType === 'list'}"
|
||||
:class="{'is-active': $route.name === 'list.list'}"
|
||||
:to="{ name: 'list.list', params: { listId: listId } }">
|
||||
{{ $t('list.list.title') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
v-shortcut="'g g'"
|
||||
:title="$t('keyboardShortcuts.list.switchToGanttView')"
|
||||
:class="{'is-active': currentListType === 'gantt'}"
|
||||
:class="{'is-active': $route.name === 'list.gantt'}"
|
||||
:to="{ name: 'list.gantt', params: { listId: listId } }">
|
||||
{{ $t('list.gantt.title') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
v-shortcut="'g t'"
|
||||
:title="$t('keyboardShortcuts.list.switchToTableView')"
|
||||
:class="{'is-active': currentListType === 'table'}"
|
||||
:class="{'is-active': $route.name === 'list.table'}"
|
||||
:to="{ name: 'list.table', params: { listId: listId } }">
|
||||
{{ $t('list.table.title') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
v-shortcut="'g k'"
|
||||
:title="$t('keyboardShortcuts.list.switchToKanbanView')"
|
||||
:class="{'is-active': currentListType === 'kanban'}"
|
||||
:class="{'is-active': $route.name === 'list.kanban'}"
|
||||
:to="{ name: 'list.kanban', params: { listId: listId } }">
|
||||
{{ $t('list.kanban.title') }}
|
||||
</router-link>
|
||||
|
@ -69,11 +69,6 @@ export default {
|
|||
},
|
||||
},
|
||||
computed: {
|
||||
currentListType() {
|
||||
// default: 'list',
|
||||
return ''
|
||||
},
|
||||
|
||||
// Computed property to let "listId" always have a value
|
||||
listId() {
|
||||
return typeof this.$route.params.listId === 'undefined' ? 0 : this.$route.params.listId
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div
|
||||
:class="{ 'is-loading': taskCollectionService.loading }"
|
||||
:class="{ 'is-loading': loading }"
|
||||
class="loader-container is-max-width-desktop list-view"
|
||||
>
|
||||
<div
|
||||
|
@ -26,7 +26,7 @@
|
|||
</div>
|
||||
<div class="control">
|
||||
<x-button
|
||||
:loading="taskCollectionService.loading"
|
||||
:loading="loading"
|
||||
@click="searchTasks"
|
||||
:shadow="false"
|
||||
>
|
||||
|
@ -59,7 +59,7 @@
|
|||
/>
|
||||
</template>
|
||||
|
||||
<nothing v-if="ctaVisible && tasks.length === 0 && !taskCollectionService.loading">
|
||||
<nothing v-if="ctaVisible && tasks.length === 0 && !loading">
|
||||
{{ $t('list.list.empty') }}
|
||||
<a @click="focusNewTaskInput()">
|
||||
{{ $t('list.list.newTaskCta') }}
|
||||
|
@ -117,8 +117,8 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
:total-pages="taskCollectionService.totalPages"
|
||||
<Pagination
|
||||
:total-pages="totalPages"
|
||||
:current-page="currentPage"
|
||||
/>
|
||||
</card>
|
||||
|
@ -130,13 +130,16 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import TaskService from '../../../services/task'
|
||||
import TaskModel from '../../../models/task'
|
||||
|
||||
import EditTask from '../../../components/tasks/edit-task'
|
||||
import AddTask from '../../../components/tasks/add-task'
|
||||
import SingleTaskInList from '../../../components/tasks/partials/singleTaskInList'
|
||||
import taskList from '../../../components/tasks/mixins/taskList'
|
||||
import { createTaskList } from '@/composables/taskList'
|
||||
import {saveListView} from '@/helpers/saveListView'
|
||||
import Rights from '../../../models/constants/rights.json'
|
||||
import FilterPopup from '@/components/list/partials/filter-popup.vue'
|
||||
|
@ -172,8 +175,6 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
taskService: new TaskService(),
|
||||
isTaskEdit: false,
|
||||
taskEditTask: TaskModel,
|
||||
ctaVisible: false,
|
||||
showTaskSearch: false,
|
||||
|
||||
|
@ -184,9 +185,6 @@ export default {
|
|||
},
|
||||
}
|
||||
},
|
||||
mixins: [
|
||||
taskList,
|
||||
],
|
||||
components: {
|
||||
Nothing,
|
||||
FilterPopup,
|
||||
|
@ -199,15 +197,28 @@ export default {
|
|||
},
|
||||
|
||||
setup() {
|
||||
return {
|
||||
showTaskDetail: useShowModal(),
|
||||
}
|
||||
},
|
||||
const taskEditTask = ref(TaskModel)
|
||||
const isTaskEdit = ref(false)
|
||||
|
||||
// This function initializes the tasks page and loads the first page of tasks
|
||||
function beforeLoad() {
|
||||
taskEditTask.value = null
|
||||
isTaskEdit.value = false
|
||||
}
|
||||
|
||||
const taskList = createTaskList(beforeLoad)
|
||||
|
||||
created() {
|
||||
// Save the current list view to local storage
|
||||
// We use local storage and not vuex here to make it persistent across reloads.
|
||||
saveListView(this.$route.params.listId, this.$route.name)
|
||||
const route = useRoute()
|
||||
saveListView(route.params.listId, route.name)
|
||||
|
||||
return {
|
||||
taskEditTask,
|
||||
isTaskEdit,
|
||||
showTaskDetail: useShowModal(),
|
||||
...taskList,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAlphabeticalSorting() {
|
||||
|
@ -247,17 +258,11 @@ export default {
|
|||
// When clicking on the search button, @blur from the input is fired. If we
|
||||
// would then directly hide the whole search bar directly, no click event
|
||||
// from the button gets fired. To prevent this, we wait 200ms until we hide
|
||||
// everything so the button has a chance of firering the search event.
|
||||
// everything so the button has a chance of firing the search event.
|
||||
setTimeout(() => {
|
||||
this.showTaskSearch = false
|
||||
}, 200)
|
||||
},
|
||||
// This function initializes the tasks page and loads the first page of tasks
|
||||
initTasks(page, search = '') {
|
||||
this.taskEditTask = null
|
||||
this.isTaskEdit = false
|
||||
this.loadTasks(page, search)
|
||||
},
|
||||
focusNewTaskInput() {
|
||||
this.$refs.newTaskInput.$refs.newTaskInput.focus()
|
||||
},
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div :class="{'is-loading': taskCollectionService.loading}" class="table-view loader-container">
|
||||
<div :class="{'is-loading': loading}" class="table-view loader-container">
|
||||
<div class="filter-container">
|
||||
<div class="items">
|
||||
<popup>
|
||||
|
@ -169,7 +169,7 @@
|
|||
</div>
|
||||
|
||||
<Pagination
|
||||
:total-pages="taskCollectionService.totalPages"
|
||||
:total-pages="totalPages"
|
||||
:current-page="currentPage"
|
||||
/>
|
||||
</card>
|
||||
|
@ -185,9 +185,10 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import {useRoute} from 'vue-router'
|
||||
import { defineComponent, ref, reactive, computed, toRaw } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
import taskList from '@/components/tasks/mixins/taskList'
|
||||
import { createTaskList } from '@/composables/taskList'
|
||||
import Done from '@/components/misc/Done.vue'
|
||||
import User from '@/components/misc/user'
|
||||
import PriorityLabel from '@/components/tasks/partials/priorityLabel'
|
||||
|
@ -200,7 +201,39 @@ import FilterPopup from '@/components/list/partials/filter-popup.vue'
|
|||
import Pagination from '@/components/misc/pagination.vue'
|
||||
import Popup from '@/components/misc/popup'
|
||||
|
||||
export default {
|
||||
const ACTIVE_COLUMNS_DEFAULT = {
|
||||
id: true,
|
||||
done: true,
|
||||
title: true,
|
||||
priority: false,
|
||||
labels: true,
|
||||
assignees: true,
|
||||
dueDate: true,
|
||||
startDate: false,
|
||||
endDate: false,
|
||||
percentDone: false,
|
||||
created: false,
|
||||
updated: false,
|
||||
createdBy: false,
|
||||
}
|
||||
|
||||
const SORT_BY_DEFAULT = {
|
||||
id: 'desc',
|
||||
}
|
||||
|
||||
function useSavedView(activeColumns, sortBy) {
|
||||
const savedShowColumns = localStorage.getItem('tableViewColumns')
|
||||
if (savedShowColumns !== null) {
|
||||
Object.assign(activeColumns, JSON.parse(savedShowColumns))
|
||||
}
|
||||
|
||||
const savedSortBy = localStorage.getItem('tableViewSortBy')
|
||||
if (savedSortBy !== null) {
|
||||
sortBy.value = JSON.parse(savedSortBy)
|
||||
}
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Table',
|
||||
components: {
|
||||
Popup,
|
||||
|
@ -214,75 +247,18 @@ export default {
|
|||
User,
|
||||
Pagination,
|
||||
},
|
||||
mixins: [
|
||||
taskList,
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
activeColumns: {
|
||||
id: true,
|
||||
done: true,
|
||||
title: true,
|
||||
priority: false,
|
||||
labels: true,
|
||||
assignees: true,
|
||||
dueDate: true,
|
||||
startDate: false,
|
||||
endDate: false,
|
||||
percentDone: false,
|
||||
created: false,
|
||||
updated: false,
|
||||
createdBy: false,
|
||||
},
|
||||
sortBy: {
|
||||
id: 'desc',
|
||||
},
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
taskDetailRoutes() {
|
||||
const taskDetailRoutes = {}
|
||||
this.tasks.forEach(({id}) => {
|
||||
taskDetailRoutes[id] = {
|
||||
name: 'task.detail',
|
||||
params: { id },
|
||||
state: { backgroundView: this.$router.currentRoute.value.fullPath },
|
||||
}
|
||||
})
|
||||
return taskDetailRoutes
|
||||
},
|
||||
},
|
||||
created() {
|
||||
const savedShowColumns = localStorage.getItem('tableViewColumns')
|
||||
if (savedShowColumns !== null) {
|
||||
this.activeColumns = JSON.parse(savedShowColumns)
|
||||
}
|
||||
const savedSortBy = localStorage.getItem('tableViewSortBy')
|
||||
if (savedSortBy !== null) {
|
||||
this.sortBy = JSON.parse(savedSortBy)
|
||||
}
|
||||
|
||||
this.params.filter_by = []
|
||||
this.params.filter_value = []
|
||||
this.params.filter_comparator = []
|
||||
|
||||
this.initTasks(1)
|
||||
|
||||
},
|
||||
setup() {
|
||||
// Save the current list view to local storage
|
||||
// We use local storage and not vuex here to make it persistent across reloads.
|
||||
const route = useRoute()
|
||||
console.log(route.value)
|
||||
saveListView(route.value.params.listId, route.value.name)
|
||||
},
|
||||
methods: {
|
||||
initTasks(page, search = '') {
|
||||
const activeColumns = reactive({ ...ACTIVE_COLUMNS_DEFAULT })
|
||||
const sortBy = ref({ ...SORT_BY_DEFAULT })
|
||||
|
||||
useSavedView(activeColumns, sortBy)
|
||||
|
||||
function beforeLoad(params) {
|
||||
// This makes sure an id sort order is always sorted last.
|
||||
// When tasks would be sorted first by id and then by whatever else was specified, the id sort takes
|
||||
// precedence over everything else, making any other sort columns pretty useless.
|
||||
const sortKeys = Object.keys(this.sortBy)
|
||||
let hasIdFilter = false
|
||||
const sortKeys = Object.keys(sortBy.value)
|
||||
for (const s of sortKeys) {
|
||||
if (s === 'id') {
|
||||
sortKeys.splice(s, 1)
|
||||
|
@ -293,50 +269,80 @@ export default {
|
|||
if (hasIdFilter) {
|
||||
sortKeys.push('id')
|
||||
}
|
||||
|
||||
const params = this.params
|
||||
params.sort_by = []
|
||||
params.order_by = []
|
||||
sortKeys.map(s => {
|
||||
params.sort_by.push(s)
|
||||
params.order_by.push(this.sortBy[s])
|
||||
})
|
||||
this.loadTasks(page, search, params)
|
||||
},
|
||||
sort(property) {
|
||||
const order = this.sortBy[property]
|
||||
params.value.sort_by = sortKeys
|
||||
params.value.order_by = sortKeys.map(s => sortBy.value[s])
|
||||
}
|
||||
|
||||
const taskList = createTaskList(beforeLoad)
|
||||
|
||||
Object.assign(taskList.params.value, {
|
||||
filter_by: [],
|
||||
filter_value: [],
|
||||
filter_comparator: [],
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const taskDetailRoutes = computed(() => Object.fromEntries(
|
||||
taskList.tasks.value.map(({id}) => ([
|
||||
id,
|
||||
{
|
||||
name: 'task.detail',
|
||||
params: { id },
|
||||
state: { backgroundView: router.currentRoute.value.fullPath },
|
||||
},
|
||||
])),
|
||||
))
|
||||
|
||||
// Save the current list view to local storage
|
||||
// We use local storage and not vuex here to make it persistent across reloads.
|
||||
const route = useRoute()
|
||||
saveListView(route.params.listId, route.name)
|
||||
|
||||
function sort(property) {
|
||||
const order = sortBy.value[property]
|
||||
if (typeof order === 'undefined' || order === 'none') {
|
||||
this.sortBy[property] = 'desc'
|
||||
sortBy.value[property] = 'desc'
|
||||
} else if (order === 'desc') {
|
||||
this.sortBy[property] = 'asc'
|
||||
sortBy.value[property] = 'asc'
|
||||
} else {
|
||||
delete this.sortBy[property]
|
||||
delete sortBy.value[property]
|
||||
}
|
||||
this.initTasks(this.currentPage, this.searchTerm)
|
||||
beforeLoad(taskList.currentPage.value, taskList.searchTerm.value)
|
||||
// Save the order to be able to retrieve them later
|
||||
localStorage.setItem('tableViewSortBy', JSON.stringify(this.sortBy))
|
||||
},
|
||||
saveTaskColumns() {
|
||||
localStorage.setItem('tableViewColumns', JSON.stringify(this.activeColumns))
|
||||
},
|
||||
localStorage.setItem('tableViewSortBy', JSON.stringify(sortBy.value))
|
||||
}
|
||||
|
||||
function saveTaskColumns() {
|
||||
localStorage.setItem('tableViewColumns', JSON.stringify(toRaw(activeColumns)))
|
||||
}
|
||||
|
||||
taskList.initTaskList()
|
||||
|
||||
return {
|
||||
...taskList,
|
||||
sortBy,
|
||||
activeColumns,
|
||||
sort,
|
||||
saveTaskColumns,
|
||||
taskDetailRoutes,
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.table-view {
|
||||
.table {
|
||||
background: transparent;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
.table {
|
||||
background: transparent;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
|
||||
th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user {
|
||||
margin: 0;
|
||||
}
|
||||
.user {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue