@itfin/components 1.3.86 → 1.3.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ <template>
2
+ <div>
3
+ <div class="px-1">
4
+ <div v-if="title" class="fw-bold mb-1">{{title}}</div>
5
+
6
+ <itf-text-field small v-model="query" class="mb-2" :placeholder="$t('components.filter.search')" clearable />
7
+
8
+ <div v-if="mappedValues.length" class="d-flex justify-content-between small mb-2">
9
+ <a v-if="isSelectedAll" href="" @click.stop.prevent="onSelectAll(false)">{{$t('components.filter.deselectAll')}}</a>
10
+ <a v-else href="" @click.stop.prevent="onSelectAll(true)">{{$t('components.filter.selectAll')}}</a>
11
+
12
+ <span class="text-muted" v-if="!isHasSelected" href="" @click.stop.prevent="onShowSelected(true)">{{$t('components.filter.showSelected')}}</span>
13
+ <a v-else-if="!isShowSelected" href="" @click.stop.prevent="onShowSelected(true)">{{$t('components.filter.showSelected')}}</a>
14
+ <a v-else href="" @click.stop.prevent="onShowSelected(false)">{{$t('components.filter.showSelected')}}</a>
15
+ </div>
16
+ <div v-else>
17
+ <div class="text-muted text-center py-4">{{ $t('components.filter.noResults') }}</div>
18
+ </div>
19
+ </div>
20
+ <div v-for="(val, n) of mappedValues" :key="n" class="facet-item" :class="{'active': val.isSelected}" @click="onFilterClick(val)">
21
+ <span class="facet-name text-dark d-flex align-items-center">
22
+ <itf-checkbox ungrouped :value="val.isSelected" class="m-0" />
23
+ <div class="w-100 text-truncate">{{ val.label }}</div>
24
+ </span>
25
+ <span class="facet-stat text-muted">
26
+ {{ val.count }}
27
+ <span class="facet-bar"><span :style="{'--bar-width': `${getPercent(val)}%`}" class="facet-bar-progress" /></span>
28
+ </span>
29
+ </div>
30
+
31
+ <itf-button class="mt-1" v-if="hasMore" small block @click="toggleMore">
32
+ <span v-if="showMore">{{ $t('components.filter.hideMore', { count: visibleList.length }) }}</span>
33
+ <span v-else>{{ $t('components.filter.showMore', { count: visibleList.length }) }}</span>
34
+ </itf-button>
35
+ </div>
36
+ </template>
37
+ <style lang="scss" scoped>
38
+ .facet-item {
39
+ background: transparent;
40
+ cursor: pointer;
41
+ display: inline-flex;
42
+ -webkit-box-align: center;
43
+ align-items: center;
44
+ -webkit-box-pack: justify;
45
+ justify-content: space-between;
46
+ position: relative;
47
+ box-sizing: border-box;
48
+ height: 1.75rem;
49
+ width: 100%;
50
+ padding-left: 0.25rem;
51
+ padding-right: 0.25rem;
52
+ font-size: 0.875rem;
53
+ line-height: 1.25rem;
54
+ font-weight: 400;
55
+ white-space: normal;
56
+ user-select: none;
57
+ border-radius: 0.25rem;
58
+ border-width: 1px;
59
+ border-style: solid;
60
+ border-color: transparent;
61
+ border-image: initial;
62
+ transition: none 0s ease 0s;
63
+ margin: 1px 0;
64
+ &.active {
65
+ background-color: rgba(var(--bs-primary-rgb), 25%);
66
+
67
+ .facet-bar-progress {
68
+ background-color: rgba(var(--bs-primary-rgb), 75%);
69
+ }
70
+ }
71
+ &:hover, &:focus, &.active {
72
+ border-color: var(--bs-primary);
73
+ }
74
+ .facet-name {
75
+ text-align: left;
76
+ white-space: nowrap;
77
+
78
+ .itf-checkbox {
79
+ min-height: auto;
80
+ }
81
+ }
82
+ .facet-stat {
83
+ display: flex;
84
+ -webkit-box-align: center;
85
+ align-items: center;
86
+ margin-left: 0.25rem;
87
+ }
88
+ .facet-bar {
89
+ display: inline-block;
90
+ margin-left: 0.5rem;
91
+ width: 60px;
92
+ }
93
+ .facet-bar-progress {
94
+ display: block;
95
+ width: var(--bar-width);
96
+ min-width: 5px;
97
+ height: 10px;
98
+ background-color: rgb(197, 205, 223);
99
+ transition: width 0.3s ease 0s;
100
+ }
101
+ }
102
+ </style>
103
+ <script>
104
+ import { Vue, Prop, Model, Component } from 'vue-property-decorator';
105
+ import itfTextField from '../text-field/TextField.vue';
106
+ import itfButton from '../button/Button';
107
+ import itfCheckbox from '../checkbox/Checkbox.vue';
108
+
109
+ export default @Component({
110
+ name: 'FilterFacetsList',
111
+ components: {
112
+ itfCheckbox,
113
+ itfButton,
114
+ itfTextField
115
+ }
116
+ })
117
+ class FilterFacetsList extends Vue {
118
+ @Model('input') value;
119
+ @Prop() items;
120
+ @Prop() item;
121
+ @Prop() total;
122
+ @Prop({ type: Number, default: 5 }) limit;
123
+ @Prop(Boolean) multiple;
124
+ @Prop(Boolean) showAll;
125
+ @Prop(String) title;
126
+
127
+ query = '';
128
+ showMore = false;
129
+ isShowSelected = false;
130
+
131
+ toggleMore() {
132
+ this.showMore = !this.showMore;
133
+ }
134
+
135
+ get hasMore() {
136
+ if (this.showAll) {
137
+ return false;
138
+ }
139
+ return this.visibleList.length > this.limit;
140
+ }
141
+
142
+ get visibleList() {
143
+ let list = this.items.map(val => {
144
+ const isSelected = this.multiple
145
+ ? Array.isArray(this.value) && this.value.map(String).includes(`${val.value}`)
146
+ : `${this.value}` === `${val.value}`;
147
+
148
+ return { ...val, isSelected };
149
+ });
150
+ if (this.query) {
151
+ list = list.filter((val) => val.label.toLowerCase().includes(this.query.toLowerCase()));
152
+ }
153
+ if (this.isShowSelected) {
154
+ return list.filter((val) => val.isSelected);
155
+ }
156
+ return list;
157
+ }
158
+
159
+ get mappedValues() {
160
+ const list = this.visibleList;
161
+ if (!this.showMore && !this.showAll) {
162
+ return list.slice(0, this.limit);
163
+ }
164
+ return list;
165
+ }
166
+
167
+ getPercent(item) {
168
+ return this.total ? Math.round((item.count / this.total) * 100) : 0;
169
+ }
170
+
171
+ onFilterClick(val) {
172
+ const value = `${val.value}`;
173
+ if (!this.multiple) {
174
+ return this.$emit('input', `${this.value}` === value ? null : value);
175
+ }
176
+ const newVal = [...Array.isArray(this.value) ? [...this.value] : []].map((s) => s.toString());
177
+ if (newVal.includes(value)) {
178
+ newVal.splice(newVal.indexOf(value), 1);
179
+ } else {
180
+ newVal.push(value);
181
+ }
182
+ this.$emit('input', newVal);
183
+ }
184
+
185
+ get isSelectedAll() {
186
+ return this.mappedValues.every(o => o.isSelected);
187
+ }
188
+
189
+ onSelectAll(isSelect = false) {
190
+ if (isSelect) {
191
+ this.$emit('input', this.visibleList.map((val) => `${val.value}`));
192
+ } else {
193
+ this.$emit('input', []);
194
+ }
195
+ this.isShowSelected = false;
196
+ }
197
+
198
+ get isHasSelected() {
199
+ return this.value && this.value.length > 0 && !this.query; // тільки коли не пошук
200
+ }
201
+
202
+ onShowSelected(isShow = false) {
203
+ this.isShowSelected = isShow;
204
+ }
205
+ }
206
+ </script>
@@ -0,0 +1,184 @@
1
+ <template>
2
+ <div class="d-flex gap-2 align-items-center flex-wrap">
3
+ <template v-if="search">
4
+ <itf-text-field
5
+ style="width: 200px"
6
+ small
7
+ :placeholder="$t('components.filter.search')"
8
+ prepend---icon="search"
9
+ :delay-input="250"
10
+ clearable
11
+ :value="filterValue.query"
12
+ @input="(e) => onFilterChange({ type: 'text', name: 'query' }, { value: e })"
13
+ />
14
+ </template>
15
+ <filter-badge
16
+ v-for="(facet, n) in filters"
17
+ :key="n"
18
+ v-model="filter[facet.name]"
19
+ :is-default="filter[facet.name].isDefault"
20
+ :text="filter[facet.name].label"
21
+ :type="facet.type"
22
+ :icon="facet.icon"
23
+ :options="facet.options"
24
+ @change="onFilterChange(facet, $event)"
25
+ />
26
+ <div v-if="loading">
27
+ <span class="itf-spinner"></span>
28
+ {{$t('loading')}}
29
+ </div>
30
+ </div>
31
+ </template>
32
+ <style lang="scss">
33
+ </style>
34
+ <script>
35
+ import { DateTime } from 'luxon';
36
+ import { Vue, Model, Prop, Component } from 'vue-property-decorator';
37
+ import tooltip from '../../directives/tooltip';
38
+ import itfIcon from '../icon/Icon';
39
+ import itfButton from '../button/Button';
40
+ import itfDropdown from '../dropdown/Dropdown.vue';
41
+ import itfTextField from '../text-field/TextField.vue';
42
+ import { formatDateonly, formatMoney, formatRangeDates } from '../../helpers/formatters';
43
+ import FilterBadge from './FilterBadge.vue';
44
+
45
+ export default @Component({
46
+ components: {
47
+ itfIcon,
48
+ itfButton,
49
+ itfDropdown,
50
+ itfTextField,
51
+ FilterBadge
52
+ },
53
+ directives: {
54
+ tooltip
55
+ },
56
+ filters: {
57
+ formatMoney,
58
+ formatDateonly
59
+ }
60
+ })
61
+ class FilterPanel extends Vue {
62
+ @Model('input') value;
63
+ @Prop({ type: String }) endpoint;
64
+ @Prop() panel;
65
+ @Prop(Boolean) search;
66
+
67
+ filter = {};
68
+ filterValue = {};
69
+ filters = [];
70
+ loading = false;
71
+
72
+ async mounted() {
73
+ this.loading = true;
74
+ await this.$try(async () => {
75
+ const { filters } = await this.$axios.$get(this.endpoint);
76
+ this.filters = filters;
77
+ this.loadFiltersValue();
78
+ });
79
+ this.loading = false;
80
+ this.panel.on('panels.changed', () => this.loadFiltersValue());
81
+ }
82
+
83
+ loadFiltersValue() {
84
+ const payload = this.panel ? this.panel.getPayload() : {};
85
+ const filter = {};
86
+ const filterValue = {};
87
+ for (const item of this.filters) {
88
+ filter[item.name] = payload[item.name] ? this.formatValue(item, { value: payload[item.name] }) : { isDefault: true, ...item.options.defaultValue };
89
+ if (item.type === 'period') {
90
+ filterValue.from = payload.from;
91
+ filterValue.to = payload.to;
92
+ } else {
93
+ filterValue[item.name] = payload[item.name];
94
+ }
95
+ }
96
+ if (this.search) {
97
+ filterValue.query = payload.query;
98
+ }
99
+ this.filter = filter;
100
+ this.filterValue = filterValue;
101
+ this.$emit('input', this.filterValue);
102
+ }
103
+
104
+ onFilterChange(facet, value) {
105
+ this.filter[facet.name] = this.formatValue(facet, value);
106
+ if (facet.type === 'period') {
107
+ this.filterValue.from = this.filter[facet.name].isDefault ? undefined : value.value[0];
108
+ this.filterValue.to = this.filter[facet.name].isDefault ? undefined : value.value[1];
109
+ } else {
110
+ this.filterValue[facet.name] = this.filter[facet.name].isDefault ? undefined : value.value;
111
+ }
112
+ if (this.panel) {
113
+ const payload = this.panel.getPayload();
114
+ this.panel.setPayload({ ...payload, ...this.filterValue });
115
+ }
116
+ this.$emit('input', this.filterValue);
117
+ }
118
+
119
+ get daysList() {
120
+ return [// { title: 'Today', date: () => [DateTime.local(), DateTime.local()] },
121
+ { title: this.$t('components.thisWeek'), date: () => [DateTime.local().startOf('week'), DateTime.local().endOf('week')] },
122
+ { title: this.$t('components.lastWeek'), date: () => [DateTime.local().minus({ week: 1 }).startOf('week'), DateTime.local().minus({ week: 1 }).endOf('week')] },
123
+ { title: this.$t('components.thisMonth'), date: () => [DateTime.local().startOf('month'), DateTime.local().endOf('month')] },
124
+ { title: this.$t('components.lastMonth'), date: () => [DateTime.local().minus({ months: 1 }).startOf('month'), DateTime.local().minus({ months: 1 }).endOf('month')] },
125
+ { title: this.$t('components.thisQuarter'), date: () => [DateTime.local().startOf('quarter'), DateTime.local().endOf('quarter')] },
126
+ { title: this.$t('components.lastQuarter'), date: () => [DateTime.local().minus({ quarter: 1 }).startOf('quarter'), DateTime.local().minus({ quarter: 1 }).endOf('quarter')] },
127
+ { title: this.$t('components.thisYear'), date: () => [DateTime.local().startOf('year'), DateTime.local().endOf('year')] },
128
+ { title: this.$t('components.lastYear'), date: () => [DateTime.local().minus({ year: 1 }).startOf('year'), DateTime.local().minus({ year: 1 }).endOf('year')] },
129
+ ];
130
+ }
131
+
132
+ formatValue(facet, value) {
133
+ if (facet.type === 'period') {
134
+ if (value.value) {
135
+ let from = DateTime.fromISO(value.value[0]);
136
+ let to = DateTime.fromISO(value.value[1]);
137
+ if (!from.isValid || !to.isValid) {
138
+ from = DateTime.fromISO(facet.options.defaultValue.value[0]);
139
+ to = DateTime.fromISO(facet.options.defaultValue.value[1]);
140
+ }
141
+ const namedItem = this.daysList.find(item => {
142
+ const [start, end] = item.date();
143
+ return start.equals(from.startOf('day')) && end.equals(to.endOf('day'));
144
+ });
145
+ if (namedItem) {
146
+ value.label = namedItem.title;
147
+ } else {
148
+ value.label = formatRangeDates(from, to);
149
+ }
150
+ }
151
+ if (facet.options.defaultValue?.value) {
152
+ value.isDefault = facet.options.defaultValue ? value.value[0] === facet.options.defaultValue.value[0] && value.value[1] === facet.options.defaultValue.value[1] : false;
153
+ } else {
154
+ value.isDefault = !value.value;
155
+ }
156
+ } else if (facet.type === 'date') {
157
+ const date = DateTime.fromISO(value.value);
158
+ value.label = formatDateonly(date.isValid ? value.value : facet.options.defaultValue.value, 'dd MMM yyyy');
159
+ value.isDefault = facet.options.defaultValue ? value.value === facet.options.defaultValue.value : false;
160
+ } else if (facet.type === 'facets-list') {
161
+ const firstItem = facet.options.items.find(item => item.value === (Array.isArray(value.value) ? value.value[0] : value.value));
162
+ value.label = firstItem ? firstItem.label : facet.options.defaultValue.label;
163
+ if (value.value && value.value.length > 1) {
164
+ value.label = `${value.label} та ще ${value.value.length - 1}`;
165
+ }
166
+ value.isDefault = facet.options.defaultValue ? JSON.stringify(value.value) === JSON.stringify(facet.options.defaultValue.value) : false;
167
+ } else if (facet.type === 'amount') {
168
+ if (typeof value.value[0] !== 'number' || typeof value.value[1] !== 'number') {
169
+ value.value = [facet.options.min, facet.options.max];
170
+ }
171
+ value.isDefault = facet.options.defaultValue ? JSON.stringify(value.value) === JSON.stringify(facet.options.defaultValue.value) : false;
172
+ value.label = value.isDefault ? facet.options.defaultValue.label : `${formatMoney(value.value[0], null, 0)} - ${formatMoney(value.value[1], null, 0)}`;
173
+ } else if (facet.type === 'list') {
174
+ const item = facet.options.items.find(item => item.value === value.value);
175
+ value.isDefault = facet.options.defaultValue ? JSON.stringify(value.value) === JSON.stringify(facet.options.defaultValue.value) : false;
176
+ value.label = item ? item.label : facet.options.defaultValue.label;
177
+ } else if (facet.type === 'text') {
178
+ value.value = value.value.length ? value.value : undefined;
179
+ value.isDefault = !value.value;
180
+ }
181
+ return value;
182
+ }
183
+ }
184
+ </script>
@@ -79,6 +79,10 @@
79
79
  </div>
80
80
  </template>
81
81
  <style lang="scss">
82
+ @import "bootstrap/scss/_functions.scss";
83
+ @import "bootstrap/scss/_variables.scss";
84
+ @import "bootstrap/scss/mixins/_breakpoints.scss";
85
+
82
86
  .b-panel-list-container {
83
87
  width: 100%;
84
88
  height: 100%;
@@ -87,15 +91,18 @@
87
91
  }
88
92
  .b-panel-list {
89
93
  align-items: stretch;
90
- //background: var(--bs-body-bg);
94
+ //background: var(--bs-body-bg);
91
95
  bottom: 0;
92
- display: flex;
93
96
  height: 100%;
94
97
  left: 0;
95
98
  overflow-x: auto;
96
99
  position: absolute;
97
100
  right: 0;
98
101
  top: 0;
102
+
103
+ @include media-breakpoint-up(md) {
104
+ display: flex;
105
+ }
99
106
  }
100
107
 
101
108
  $an-time: .1s;
@@ -213,6 +220,7 @@ export default class PanelList extends Vue {
213
220
  newStack[index].isCollapsed = false;
214
221
  this.panelsStack = newStack;
215
222
  this.ensureOnlyTwoOpenPanels(panel.id);
223
+ this.setPanelHash();
216
224
  }
217
225
 
218
226
  ensureOnlyTwoOpenPanels(keepOpenId: number) {
@@ -244,7 +252,7 @@ export default class PanelList extends Vue {
244
252
  this.panelsStack = newStack;
245
253
  }
246
254
 
247
- internalOpenPanel(type: string, payload: any = {}, openIndex?: number) {
255
+ internalOpenPanel(type: string, payload: any = {}, openIndex?: number, noEvents = false) {
248
256
  if (!this.panels[type]) {
249
257
  return;
250
258
  }
@@ -315,13 +323,15 @@ export default class PanelList extends Vue {
315
323
  newPanel.payload = value;
316
324
  newPanel.title = this.panels[type].caption(this.$t.bind(this), value);
317
325
  newPanel.icon = this.panels[type].icon ? this.panels[type].icon(this.$t.bind(this), payload) : null,
318
- this.setPanelHash()
326
+ this.setPanelHash()
319
327
  }
320
328
  newStack.push(newPanel);
321
329
  this.panelsStack = newStack;
322
330
  this.ensureOnlyTwoOpenPanels(newPanel.id);
323
331
  this.emitEvent('panel.open', newPanel);
324
- this.emitEvent('panels.changed', this.panelsStack);
332
+ if (!noEvents) {
333
+ this.emitEvent('panels.changed', this.panelsStack);
334
+ }
325
335
  return res(newPanel);
326
336
  })
327
337
  });
@@ -365,6 +375,7 @@ export default class PanelList extends Vue {
365
375
  p.isCollapsed = p !== panel;
366
376
  }
367
377
  this.panelsStack = newStack;
378
+ this.setPanelHash();
368
379
  }
369
380
 
370
381
  getPanels(type) {
@@ -377,7 +388,7 @@ export default class PanelList extends Vue {
377
388
 
378
389
  getLink(stack: IPanel[]) {
379
390
  return stack.map(panel => {
380
- return `${panel.type}=${JSON.stringify(panel.payload || {})}`;
391
+ return `${panel.type}${panel.isCollapsed ? '' : '!'}=${JSON.stringify(panel.payload || {})}`;
381
392
  }).join('&');
382
393
  }
383
394
 
@@ -387,19 +398,33 @@ export default class PanelList extends Vue {
387
398
  }
388
399
 
389
400
  async parsePanelHash() {
390
- const hash = this.$route.hash;
401
+ const {hash} = location;
391
402
  if (hash) {
392
403
  const panels = hash.slice(1).split('&').map(item => {
393
404
  const [type, payload] = item.split('=');
405
+ const isCollapsed = !item.includes('!');
394
406
  return {
395
- type,
407
+ type: type.replace('!', ''),
408
+ isCollapsed,
396
409
  payload: JSON.parse(decodeURIComponent(payload))
397
410
  };
398
411
  });
399
412
  const newStack = [];
400
- for (const panel of panels) {
401
- const resPanel = await this.internalOpenPanel(panel.type, panel.payload);
402
- newStack.push(resPanel);
413
+ for (const panelIndex in panels) {
414
+ const panel = panels[panelIndex];
415
+ if (this.panelsStack[panelIndex] && this.panelsStack[panelIndex].type === panel.type) {
416
+ // reuse panel
417
+ this.panelsStack[panelIndex].payload = panel.payload;
418
+ this.panelsStack[panelIndex].isCollapsed = panel.isCollapsed;
419
+ newStack.push(this.panelsStack[panelIndex]);
420
+ } else {
421
+ const resPanel = await this.internalOpenPanel(panel.type, panel.payload, undefined, true);
422
+ if (resPanel) {
423
+ resPanel.isCollapsed = panel.isCollapsed;
424
+ resPanel.isAnimate = false;
425
+ newStack.push(resPanel);
426
+ }
427
+ }
403
428
  }
404
429
  this.panelsStack = newStack;
405
430
  this.emitEvent('panels.changed', this.panelsStack);