@operato/form 2.0.0-alpha.127 → 2.0.0-alpha.133

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.
@@ -80,15 +80,29 @@ export const FilterStyles = css`
80
80
  --ox-filters-input-font: normal 16px var(--theme-font);
81
81
  }
82
82
 
83
- input[from],
84
- input[to] {
85
- max-width: 44%;
83
+ ox-input-barcode {
84
+ max-width: unset;
85
+ flex: 1;
86
86
  }
87
87
 
88
- input[type*='datetime'][from],
89
- input[type*='datetime'][to] {
90
- padding-right: var(--padding-narrow);
91
- min-width: 91%;
88
+ ox-input-search {
89
+ max-width: unset;
90
+ }
91
+
92
+ ox-select {
93
+ max-width: unset;
94
+ }
95
+
96
+ ox-checkbox {
97
+ max-width: unset;
98
+ }
99
+
100
+ input[type='number'] {
101
+ max-width: unset;
102
+ }
103
+
104
+ input {
105
+ flex: 1;
92
106
  }
93
107
  }
94
108
  `
@@ -0,0 +1,195 @@
1
+ import '@material/web/button/outlined-button.js'
2
+
3
+ import { css, html, LitElement } from 'lit'
4
+ import { customElement, property, state } from 'lit/decorators.js'
5
+
6
+ import { i18next } from '@operato/i18n'
7
+ import { OxPopupList } from '@operato/popup'
8
+
9
+ import { FilterConfig, PersonalFilterPreference } from '../types.js'
10
+ import { OxCheckbox } from '@operato/input'
11
+
12
+ import { OxFiltersFormBase } from './ox-filters-form-base'
13
+
14
+ @customElement('ox-filter-personalizer')
15
+ export class OxFilterPersonalizer extends LitElement {
16
+ static styles = [
17
+ css`
18
+ md-icon {
19
+ --md-icon-size: 16px;
20
+ width: 36px;
21
+ height: 36px;
22
+ color: var(--md-sys-color-secondary-container);
23
+ cursor: pointer;
24
+
25
+ display: flex;
26
+ place-content: center;
27
+ place-items: center;
28
+ position: relative;
29
+
30
+ &:hover {
31
+ color: var(--md-sys-color-primary-container);
32
+ }
33
+ }
34
+
35
+ md-ripple {
36
+ border-radius: 50%;
37
+ inset: unset;
38
+ height: 36px;
39
+ width: 36px;
40
+ }
41
+ `
42
+ ]
43
+
44
+ @property({ type: Boolean, attribute: true }) debug: boolean = false
45
+
46
+ @state() private preference?: PersonalFilterPreference
47
+
48
+ render() {
49
+ return html`
50
+ <md-icon
51
+ @click=${async (e: MouseEvent) => {
52
+ const form = this.closest('ox-filters-form-base') as OxFiltersFormBase
53
+ const { filters, personalFilters = [] } = form
54
+ const queryFilters = await form.getQueryFilters()
55
+
56
+ this.preference = {
57
+ filters: (
58
+ personalFilters.map(filter => {
59
+ const originFilter = filters.find(f => f.name == filter.name)
60
+
61
+ if (!originFilter) {
62
+ /* 원래 filters 설정에 있는 것들 만을 유지한다. */
63
+ return
64
+ }
65
+
66
+ return {
67
+ name: filter.name,
68
+ hidden: filter.hidden,
69
+ /* 만약, filters에 기본값이 이미 설정되어 있다면, 그대로 유지한다. */
70
+ value: originFilter!.value ?? queryFilters.find(f => f.name == filter.name)?.value
71
+ }
72
+ }) || []
73
+ ).filter(Boolean) as Partial<FilterConfig>[]
74
+ }
75
+
76
+ const template = html`
77
+ <div class="personalizer-header" slot="header">
78
+ <md-icon
79
+ style="margin-left: auto;"
80
+ @click=${async (e: MouseEvent) => {
81
+ if (form.personalConfigProvider) {
82
+ form.personalConfig = await form.personalConfigProvider.save({
83
+ ...form.personalConfig,
84
+ ...this.preference
85
+ })
86
+ }
87
+ popup.close()
88
+ }}
89
+ title=${String(i18next.t('button.save'))}
90
+ >keep</md-icon
91
+ ><md-icon
92
+ @click=${async (e: MouseEvent) => {
93
+ if (form.personalConfigProvider) {
94
+ form.personalConfig = await form.personalConfigProvider.save({
95
+ ...form.personalConfig,
96
+ filters: []
97
+ })
98
+
99
+ this.preference = {}
100
+ }
101
+ popup.close()
102
+ }}
103
+ title=${String(i18next.t('button.delete'))}
104
+ >keep_off</md-icon
105
+ ><md-icon @click=${async (e: MouseEvent) => popup.close()} title=${String(i18next.t('button.close'))}
106
+ >close</md-icon
107
+ >
108
+ </div>
109
+
110
+ ${this.preference?.filters!.map(
111
+ filter => html`
112
+ <ox-checkbox label="checkbox" ?checked=${!filter.hidden} value=${filter.name} option
113
+ >${filter.name}<span style="position: absolute; right: 10px; cursor: move;" handle
114
+ >☰</span
115
+ ></ox-checkbox
116
+ >
117
+ `
118
+ )}
119
+ `
120
+
121
+ const popup = OxPopupList.open({
122
+ template,
123
+ multiple: true,
124
+ sortable: true,
125
+ debug: this.debug,
126
+ attrSelected: 'checked',
127
+ top: e.pageY,
128
+ left: e.pageX,
129
+ styles: css`
130
+ :host {
131
+ width: 240px;
132
+ max-height: 300px;
133
+ overflow: auto;
134
+ }
135
+
136
+ ::slotted(.personalizer-header) {
137
+ --md-icon-size: 1.4em;
138
+
139
+ display: flex;
140
+ flex-direction: row;
141
+ align-items: center;
142
+ text-transform: capitalize;
143
+ box-shadow: 0 3px 3px rgba(0, 0, 0, 0.3);
144
+ }
145
+
146
+ ::slotted([option]) {
147
+ position: relative;
148
+ user-select: none;
149
+ }
150
+ `
151
+ })
152
+
153
+ popup.onselect = (e: Event) => {
154
+ const selected = (e as CustomEvent).detail
155
+
156
+ const pconfig: PersonalFilterPreference = { ...form.personalConfig }
157
+ const pfilters = this.preference?.filters || form.filters
158
+
159
+ pconfig.filters = pfilters.map(filter => {
160
+ return {
161
+ name: filter.name,
162
+ hidden: selected.indexOf(filter.name) == -1,
163
+ value: filter.value
164
+ }
165
+ })
166
+
167
+ form.personalConfig = this.preference = pconfig
168
+
169
+ form.applyUpdatedConfiguration()
170
+ }
171
+
172
+ popup.addEventListener('sorted', (e: Event) => {
173
+ const sorted = (e as CustomEvent).detail as HTMLElement[]
174
+
175
+ const pconfig: PersonalFilterPreference = { ...form.personalConfig }
176
+
177
+ pconfig.filters = sorted.map(element => {
178
+ const name = (element as OxCheckbox).value
179
+ return {
180
+ name,
181
+ hidden: !element.hasAttribute('checked'),
182
+ value: filters.find(filter => filter.name == name)?.value
183
+ }
184
+ })
185
+
186
+ form.personalConfig = this.preference = pconfig
187
+
188
+ form.applyUpdatedConfiguration()
189
+ })
190
+ }}
191
+ >settings<md-ripple></md-ripple
192
+ ></md-icon>
193
+ `
194
+ }
195
+ }
@@ -5,13 +5,14 @@ import '@operato/input/ox-input-search.js'
5
5
 
6
6
  import json5 from 'json5'
7
7
  import { css, html, LitElement, nothing, PropertyValues, TemplateResult } from 'lit'
8
- import { customElement, property, queryAsync } from 'lit/decorators.js'
8
+ import { customElement, property, queryAsync, state } from 'lit/decorators.js'
9
9
 
10
10
  import { getDefaultValue } from '@operato/time-calculator'
11
+ import { PagePreferenceProvider } from '@operato/p13n'
11
12
 
12
13
  import { FilterStyles } from './filter-styles.js'
13
14
  import { getFilterRenderer } from './registry.js'
14
- import { FilterConfig, FilterOperator, FilterSelectRenderer, FilterValue } from '../types.js'
15
+ import { FilterConfig, FilterOperator, FilterSelectRenderer, FilterValue, PersonalFilterPreference } from '../types.js'
15
16
 
16
17
  export type QueryFilterRangeValue = [from: number, to: number]
17
18
 
@@ -43,8 +44,9 @@ export class OxFiltersFormBase extends LitElement {
43
44
  }
44
45
 
45
46
  @media only screen and (max-width: 460px) {
46
- label[between] {
47
- display: block;
47
+ form {
48
+ flex-direction: column;
49
+ flex-flow: column;
48
50
  }
49
51
  }
50
52
  `
@@ -54,6 +56,10 @@ export class OxFiltersFormBase extends LitElement {
54
56
  @property({ type: Array }) filters: FilterConfig[] = []
55
57
  @property({ type: Boolean, attribute: 'url-params-sensitive' }) urlParamsSensitive?: boolean
56
58
  @property({ type: Boolean, attribute: 'autofocus' }) autofocus: boolean = true
59
+ @property({ type: Object }) personalConfigProvider?: PagePreferenceProvider
60
+
61
+ @state() personalConfig?: PersonalFilterPreference
62
+ @state() personalFilters?: FilterConfig[]
57
63
 
58
64
  @queryAsync('form') form!: HTMLFormElement
59
65
 
@@ -73,7 +79,7 @@ export class OxFiltersFormBase extends LitElement {
73
79
  operator,
74
80
  value: defaultValue,
75
81
  onchange
76
- } = this.filters.find(filter => filter.name == name) || {}
82
+ } = (this.personalFilters || []).find(filter => filter.name == name) || {}
77
83
 
78
84
  if (this.autoUpdateTargetsOnChange[name]) {
79
85
  /*
@@ -112,7 +118,7 @@ export class OxFiltersFormBase extends LitElement {
112
118
  return
113
119
  }
114
120
  if (operator == 'between') {
115
- return (defaultValue as Array<any>).map(v => getDefaultValue(v, this))
121
+ return defaultValue instanceof Array && (defaultValue as Array<any>).map(v => getDefaultValue(v, this))
116
122
  } else {
117
123
  return getDefaultValue(defaultValue, this)
118
124
  }
@@ -125,35 +131,39 @@ export class OxFiltersFormBase extends LitElement {
125
131
  e.preventDefault()
126
132
  }}
127
133
  >
128
- ${this.filters.map((filter: FilterConfig) => {
129
- const { name, label, type, operator, value: defaultValue } = filter
130
- const idx = operator === 'between' ? 1 : 0
131
- const renderer = getFilterRenderer(
132
- operator === 'like' || operator === 'i_like' || operator === 'i_nlike' || operator === 'nlike'
133
- ? 'text'
134
- : type
135
- )[idx]
136
-
137
- const value =
138
- this.value?.find(filter => filter.name == name)?.value ?? this.buildDefaultValue(operator!, defaultValue)
139
-
140
- if (!renderer) {
141
- return nothing
142
- }
134
+ ${(this.personalFilters || [])
135
+ .filter(filter => !filter.hidden)
136
+ .map((filter: FilterConfig) => {
137
+ const { name, label, type, operator, value: defaultValue } = filter
138
+ const idx = operator === 'between' ? 1 : 0
139
+ const renderer = getFilterRenderer(
140
+ operator === 'like' || operator === 'i_like' || operator === 'i_nlike' || operator === 'nlike'
141
+ ? 'text'
142
+ : type
143
+ )[idx]
144
+
145
+ const value =
146
+ this.value?.find(filter => filter.name == name)?.value ?? this.buildDefaultValue(operator!, defaultValue)
147
+
148
+ if (!renderer) {
149
+ return nothing
150
+ }
143
151
 
144
- return type === 'search'
145
- ? html`<ox-input-search name="search" .value=${value} ?autofocus=${this.autofocus}></ox-input-search>`
146
- : type === 'boolean' || type === 'checkbox'
147
- ? renderer(filter, value, this)
148
- : type !== 'select'
149
- ? html`
150
- <label filter-title ?between=${operator == 'between'}
151
- ><span>${label}</span> ${renderer(filter, value, this)}
152
- </label>
153
- `
154
- : this.renderSelect(filter, value, renderer)
155
- })}
152
+ return type === 'search'
153
+ ? html`<ox-input-search name="search" .value=${value} ?autofocus=${this.autofocus}></ox-input-search>`
154
+ : type === 'boolean' || type === 'checkbox'
155
+ ? renderer(filter, value, this)
156
+ : type !== 'select'
157
+ ? html`
158
+ <label filter-title ?between=${operator == 'between'}
159
+ ><span>${label}</span> ${renderer(filter, value, this)}
160
+ </label>
161
+ `
162
+ : this.renderSelect(filter, value, renderer)
163
+ })}
156
164
  </form>
165
+
166
+ <slot name="setting"></slot>
157
167
  `
158
168
  }
159
169
 
@@ -203,10 +213,6 @@ export class OxFiltersFormBase extends LitElement {
203
213
  `
204
214
  }
205
215
 
206
- getFieldByName(name: string) {
207
- return this.renderRoot.querySelector(`[name=${name}]`)
208
- }
209
-
210
216
  async updated(changes: PropertyValues<this>) {
211
217
  if (changes.has('urlParamsSensitive')) {
212
218
  if (this.urlParamsSensitive) {
@@ -269,29 +275,77 @@ export class OxFiltersFormBase extends LitElement {
269
275
  }
270
276
  }
271
277
 
272
- if (changes.has('filters')) {
273
- this.autoUpdateTargetsOnChange = {}
274
- this.filters
275
- ?.filter(filter => filter.boundTo && filter.boundTo.length > 0)
276
- .map(({ name, boundTo }) => {
277
- boundTo!.forEach(to => {
278
- const origin = this.autoUpdateTargetsOnChange[to] || []
279
- if (name && !origin.includes(name)) {
280
- this.autoUpdateTargetsOnChange[to] = [...origin, name]
278
+ if (changes.has('personalConfigProvider') && this.personalConfigProvider) {
279
+ this.personalConfig = await this.personalConfigProvider.load()
280
+ } else if (changes.has('filters') || changes.has('personalConfig')) {
281
+ this.applyUpdatedConfiguration()
282
+ }
283
+ }
284
+
285
+ async applyUpdatedConfiguration() {
286
+ if (!this.personalConfig) {
287
+ this.personalFilters = [...this.filters]
288
+ } else {
289
+ const { filters: personalFilters } = this.personalConfig
290
+
291
+ if (personalFilters) {
292
+ const xfilters = this.filters.map((filter: Partial<FilterConfig>) => {
293
+ const personalFilter = personalFilters.find(pFilter => pFilter.name == filter.name)
294
+ return personalFilter ? { ...filter, ...personalFilter } : filter
295
+ })
296
+
297
+ function reorderList(a: Partial<FilterConfig>[], b: Partial<FilterConfig>[]): Partial<FilterConfig>[] {
298
+ // 결과 배열 초기화, a 배열 길이만큼 undefined로 채움
299
+ const result = new Array(a.length)
300
+
301
+ // b 배열에 없는 아이템은 원래 위치로 채움
302
+ a.forEach((item, index) => {
303
+ if (!item.name || !b.find(bi => bi.name == item.name)) {
304
+ result[index] = item
281
305
  }
282
306
  })
283
- })
284
307
 
285
- if (!this.urlParamsSensitive) {
286
- this.value = await this.getQueryFilters()
308
+ b.forEach(item => {
309
+ const ai = a.find(ai => ai.name == item.name)
310
+ if (ai) {
311
+ result[result.findIndex(slot => slot === undefined)] = ai
312
+ }
313
+ })
314
+
315
+ return result
316
+ }
317
+
318
+ // 배열 재정렬 실행
319
+ this.personalFilters = reorderList(xfilters as any, personalFilters as any) as FilterConfig[]
287
320
  }
288
321
  }
322
+
323
+ this.autoUpdateTargetsOnChange = {}
324
+
325
+ this.personalFilters
326
+ ?.filter(filter => filter.boundTo && filter.boundTo.length > 0)
327
+ .map(({ name, boundTo }) => {
328
+ boundTo!.forEach(to => {
329
+ const origin = this.autoUpdateTargetsOnChange[to] || []
330
+ if (name && !origin.includes(name)) {
331
+ this.autoUpdateTargetsOnChange[to] = [...origin, name]
332
+ }
333
+ })
334
+ })
335
+
336
+ if (!this.urlParamsSensitive) {
337
+ this.value = await this.getQueryFilters()
338
+ }
339
+ }
340
+
341
+ getFieldByName(name: string) {
342
+ return this.renderRoot.querySelector(`[name=${name}]`)
289
343
  }
290
344
 
291
345
  async getQueryFilters(): Promise<FilterValue[]> {
292
346
  const formData = new FormData(await this.form)
293
347
 
294
- var filters = this.filters
348
+ var filters = (this.personalFilters || [])
295
349
  .map((filter: FilterConfig) => {
296
350
  const { name, type, operator } = filter
297
351
 
package/src/types.ts CHANGED
@@ -63,6 +63,7 @@ export type FilterConfig = {
63
63
  | { name: string; [params: string]: any }[]
64
64
  | undefined
65
65
  boundTo?: string[]
66
+ hidden?: boolean
66
67
  onchange?: FilterChangedCallback
67
68
  }
68
69
 
@@ -97,3 +98,7 @@ export type SearchFormField = {
97
98
  handlers?: { [event: string]: EventListener }
98
99
  onchange?: SearchFilterChangedCallback
99
100
  }
101
+
102
+ export type PersonalFilterPreference = {
103
+ filters?: Partial<FilterConfig>[]
104
+ }
@@ -0,0 +1,192 @@
1
+ import '../src/filters/ox-filters-form-base.js'
2
+ import '../src/filters/ox-filter-personalizer.js'
3
+
4
+ import { html, TemplateResult } from 'lit'
5
+ import { sleep } from '@operato/utils'
6
+ import { CommonHeaderStyles } from '@operato/styles'
7
+
8
+ import { OxFiltersFormBase, FilterConfig, FilterValue } from '../src/index.js'
9
+ import { PersonalFilterPreference } from '../src/types.js'
10
+
11
+ export default {
12
+ title: 'ox-filters-form-base page-preference',
13
+ component: 'ox-filters-form-base',
14
+ argTypes: {
15
+ value: { control: 'object' },
16
+ filters: { control: 'object' },
17
+ urlParamsSensitive: { control: 'boolean' }
18
+ }
19
+ }
20
+
21
+ interface Story<T> {
22
+ (args: T): TemplateResult
23
+ args?: Partial<T>
24
+ argTypes?: Record<string, unknown>
25
+ }
26
+
27
+ interface ArgTypes {
28
+ value?: FilterValue[]
29
+ filters?: FilterConfig[]
30
+ urlParamsSensitive: boolean
31
+ }
32
+
33
+ const Template: Story<ArgTypes> = ({ value, filters, urlParamsSensitive }: ArgTypes) => html`
34
+ <link href="/themes/app-theme.css" rel="stylesheet" />
35
+ <link href="/themes/form-theme.css" rel="stylesheet" />
36
+ <link href="/themes/input-theme.css" rel="stylesheet" />
37
+
38
+ <link
39
+ href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL@20..48,100..700,0..1"
40
+ rel="stylesheet"
41
+ />
42
+ <link
43
+ href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL@20..48,100..700,0..1"
44
+ rel="stylesheet"
45
+ />
46
+ <link
47
+ href="https://fonts.googleapis.com/css2?family=Material+Symbols+Sharp:opsz,wght,FILL@20..48,100..700,0..1"
48
+ rel="stylesheet"
49
+ />
50
+
51
+ <style>
52
+ body {
53
+ height: 500px;
54
+ }
55
+ </style>
56
+
57
+ <style>
58
+ ox-filters-form-base {
59
+ --input-gap-vertical: 8px;
60
+ --input-gap-horizontal: 16px;
61
+
62
+ --ox-filters-input-placeholder-color: var(--primary-color);
63
+
64
+ --ox-filters-input-border: 1px solid rgba(0, 0, 0, 0.2);
65
+ --ox-filters-input-focus-border: 1px solid var(--primary-color);
66
+ --ox-filters-input-font: normal 14px var(--theme-font);
67
+ --ox-filters-input-color: var(--primary-text-color);
68
+ --ox-filters-input-focus-color: var(--primary-color);
69
+ --ox-filters-label-font: normal 14px var(--theme-font);
70
+ --ox-filters-label-color: var(--primary-text-color);
71
+
72
+ --ox-filters-form-gap: var(--input-gap-vertical, 8px) var(--input-gap-horizontal, 16px);
73
+ --ox-filters-input-padding: 6px 2px;
74
+ }
75
+
76
+ div.filters {
77
+ position: relative;
78
+ }
79
+
80
+ ox-filter-personalizer {
81
+ position: absolute;
82
+ top: 0;
83
+ right: 0;
84
+ }
85
+
86
+ ${CommonHeaderStyles.cssText}
87
+ </style>
88
+
89
+ <div class="header">
90
+ <div class="filters">
91
+ <ox-filters-form-base
92
+ .value=${value!}
93
+ .filters=${filters!}
94
+ ?url-params-sensitive=${urlParamsSensitive}
95
+ .personalConfigProvider=${{
96
+ async load() {
97
+ await sleep(1000)
98
+ return {
99
+ filters: [
100
+ { name: 'name', hidden: false, value: 'abc' },
101
+ { name: 'description', hidden: true }
102
+ ]
103
+ }
104
+ },
105
+ async save(preference: PersonalFilterPreference) {
106
+ await sleep(1000)
107
+ console.log('saving preference', preference)
108
+ return preference
109
+ },
110
+ async reset() {
111
+ await sleep(1000)
112
+ }
113
+ }}
114
+ @filters-change=${(e: CustomEvent) => {
115
+ console.log('filters changed', e.detail)
116
+ }}
117
+ >
118
+ <ox-filter-personalizer slot="setting"></ox-filter-personalizer>
119
+ </ox-filters-form-base>
120
+ </div>
121
+ </div>
122
+ `
123
+
124
+ export const Regular = Template.bind({})
125
+ Regular.args = {
126
+ filters: [
127
+ {
128
+ name: 'search',
129
+ type: 'search',
130
+ label: 'search',
131
+ operator: 'eq'
132
+ },
133
+ {
134
+ name: 'test',
135
+ type: 'string',
136
+ label: 'name',
137
+ operator: 'eq',
138
+ value: 'No Name'
139
+ },
140
+ {
141
+ name: 'active',
142
+ type: 'boolean',
143
+ label: 'active',
144
+ operator: 'eq',
145
+ value: true,
146
+ onchange: (value, formBase: OxFiltersFormBase) => {
147
+ const sss = formBase.getFieldByName('sss') as HTMLInputElement
148
+ sss.value = value ? 'DEF' : 'ABC'
149
+
150
+ return false
151
+ }
152
+ },
153
+ {
154
+ name: 'sss',
155
+ type: 'select',
156
+ label: 'sss',
157
+ operator: 'eq',
158
+ options: () => {
159
+ return [
160
+ {
161
+ display: '',
162
+ value: ''
163
+ },
164
+ {
165
+ display: 'ABC',
166
+ value: 'ABC'
167
+ },
168
+ {
169
+ display: 'DEF',
170
+ value: 'DEF'
171
+ }
172
+ ]
173
+ }
174
+ },
175
+ {
176
+ name: 'range',
177
+ type: 'number',
178
+ label: 'range',
179
+ operator: 'between'
180
+ },
181
+ {
182
+ name: 'date-range',
183
+ type: 'date',
184
+ label: 'date',
185
+ operator: 'between',
186
+ value: [
187
+ { name: 'week', params: { relativeWeeks: -1 } },
188
+ { name: 'today', params: { relativeDays: +1 } }
189
+ ]
190
+ }
191
+ ]
192
+ }
@@ -214,7 +214,11 @@ RegularContact.args = {
214
214
  name: 'name',
215
215
  type: 'text',
216
216
  placeholder: 'type your name.',
217
- props: { searchOper: 'i_like' }
217
+ props: { searchOper: 'i_like' },
218
+ onchange: value => {
219
+ console.log('changed', value)
220
+ return false
221
+ }
218
222
  },
219
223
  {
220
224
  label: 'email',