@things-factory/ai-assistant 10.1.258 → 10.1.260

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.
@@ -6,6 +6,58 @@
6
6
  import { readFileSync } from 'node:fs'
7
7
  import { join } from 'node:path'
8
8
 
9
+ /*
10
+ * The bar's actions are run here for real: the page action runner is the published one (`@operato/context`, loaded by
11
+ * node itself - jest does not transform node_modules), and the bar's template is read as lit would get it. lit,
12
+ * the icons and the words are stood in for, since this suite runs without a DOM.
13
+ */
14
+ jest.mock('lit', () => ({
15
+ html: (strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values }),
16
+ css: () => '',
17
+ nothing: Symbol.for('lit-nothing'),
18
+ LitElement: class {
19
+ requestUpdate() {}
20
+ }
21
+ }))
22
+ jest.mock('lit/decorators.js', () => ({ customElement: () => (it: unknown) => it, property: () => (it: unknown) => it, state: () => (it: unknown) => it }))
23
+ jest.mock('@material/web/icon/icon.js', () => ({}), { virtual: true })
24
+ jest.mock('@operato/context', () => {
25
+ /* The published runner itself: its one file has no imports, so it is turned into CommonJS here and evaluated. */
26
+ const { readFileSync } = jest.requireActual('node:fs')
27
+ const { dirname, join } = jest.requireActual('node:path')
28
+ const { transformSync } = jest.requireActual('@babel/core')
29
+ const file = join(dirname(require.resolve('@operato/context/package.json')), 'dist/src/tools/page-action.js')
30
+ const { code } = transformSync(readFileSync(file, 'utf-8'), { babelrc: false, configFile: false, plugins: ['@babel/plugin-transform-modules-commonjs'] })
31
+ const module = { exports: {} as Record<string, unknown> }
32
+ new Function('module', 'exports', code)(module, module.exports)
33
+ return { ...module.exports, PageActionStyles: '' }
34
+ })
35
+ jest.mock('@operato/i18n', () => ({ i18next: { t: (key: string) => key } }))
36
+ jest.mock('@operato/layout', () => ({ notify: jest.fn() }))
37
+ jest.mock('@operato/styles', () => ({ ButtonStyles: '', FormFieldStyles: '' }))
38
+ jest.mock('../utils/assistant-ask', () => ({ askAssistant: jest.fn() }))
39
+ jest.mock('../utils/assistant-selection', () => ({ selectedRecords: jest.fn() }))
40
+
41
+ import { notify } from '@operato/layout'
42
+
43
+ import { OxAssistantSelectionBar, SelectionAction } from './assistant-selection-bar'
44
+
45
+ type Drawn = { strings: readonly string[]; values: any[] }
46
+
47
+ /** The value bound to `name=` in the bar's button template (`?danger`, `aria-disabled`, `title`, `@click`). */
48
+ const bound = (drawn: Drawn, name: string) => {
49
+ const at = drawn.strings.findIndex(part => /\s$/.test(` ${part.slice(0, part.length - name.length - 1)}`) && part.endsWith(`${name}=`))
50
+ if (at < 0) throw new Error(`no ${name} in the template`)
51
+ return drawn.values[at]
52
+ }
53
+
54
+ const barWith = (count: number) => {
55
+ /* `renderAction` is private to the bar; the suite reaches it as the template lit would be handed. */
56
+ const bar = new (OxAssistantSelectionBar as any)()
57
+ bar.count = count
58
+ return bar as { count: number; renderAction(action: SelectionAction): Drawn }
59
+ }
60
+
9
61
  const bare = readFileSync(join(__dirname, 'assistant-selection-bar.ts'), 'utf-8').replace(/\/\*[\s\S]*?\*\//g, '')
10
62
 
11
63
  describe('the selection bar', () => {
@@ -30,7 +82,7 @@ describe('the selection bar', () => {
30
82
 
31
83
  it('every word it shows reads in every language', () => {
32
84
  const keys = [...new Set([...bare.matchAll(/'(ai-assistant\.selection\.[\w-]+)'/g)].map(([, key]) => key))]
33
- expect(keys.length).toBe(7)
85
+ expect(keys.length).toBe(8)
34
86
  for (const locale of ['en', 'ko', 'ja', 'zh', 'ms', 'es']) {
35
87
  const words = JSON.parse(readFileSync(join(__dirname, `../../translations/${locale}.json`), 'utf-8'))
36
88
  for (const key of keys) expect([locale, key, typeof words[key]]).toEqual([locale, key, 'string'])
@@ -40,3 +92,76 @@ describe('the selection bar', () => {
40
92
  }
41
93
  })
42
94
  })
95
+
96
+ describe('the selection bar\'s actions', () => {
97
+ beforeEach(() => (notify as jest.Mock).mockClear())
98
+
99
+ it('★ draws a destructive action as danger, an ordinary one not', () => {
100
+ const bar = barWith(1)
101
+ expect(bound(bar.renderAction({ title: 'delete', action: () => {}, emphasis: { danger: true } }), '?danger')).toBe(true)
102
+ expect(bound(bar.renderAction({ title: 'copy', action: () => {} }), '?danger')).toBe(false)
103
+ })
104
+
105
+ it('★ a one-row action with two rows picked does not run - it is aria-disabled and says why', async () => {
106
+ const bar = barWith(2)
107
+ const action = jest.fn()
108
+ const drawn = bar.renderAction({ title: 'open', single: true, action })
109
+ expect(bound(drawn, 'aria-disabled')).toBe('true')
110
+ expect(bound(drawn, 'title')).toBe('ai-assistant.selection.pick-one')
111
+ await bound(drawn, '@click')({})
112
+ expect(action).not.toHaveBeenCalled()
113
+ expect(notify).toHaveBeenCalledWith({ message: 'ai-assistant.selection.pick-one', level: 'info' })
114
+
115
+ const one = barWith(1).renderAction({ title: 'open', single: true, action })
116
+ expect(bound(one, 'aria-disabled')).toBe('false')
117
+ })
118
+
119
+ it('★ pressed twice while its first run is out, it runs once', async () => {
120
+ const bar = barWith(1)
121
+ let finish!: () => void
122
+ const action = jest.fn(() => new Promise<void>(resolve => (finish = resolve)))
123
+ const declared: SelectionAction = { id: 'twice', title: 'send', single: true, action }
124
+ const first = bound(bar.renderAction(declared), '@click')({})
125
+ const second = bound(bar.renderAction(declared), '@click')({})
126
+ expect(bound(bar.renderAction(declared), 'aria-disabled')).toBe('true')
127
+ finish()
128
+ await Promise.all([first, second])
129
+ await new Promise(resolve => setTimeout(resolve, 0))
130
+ expect(action).toHaveBeenCalledTimes(1)
131
+ })
132
+ })
133
+
134
+ describe('the selection bar without the assistant', () => {
135
+ /** Every template drawn under `drawn`, the outer one first - lit's nested `html` results as the mock hands them. */
136
+ const templates = (drawn: any): Drawn[] =>
137
+ drawn && typeof drawn === 'object' && Array.isArray(drawn.strings)
138
+ ? [drawn, ...drawn.values.flatMap((value: any) => (Array.isArray(value) ? value.flatMap(templates) : templates(value)))]
139
+ : []
140
+ const drawnWith = (assistant?: boolean) => {
141
+ const bar = new (OxAssistantSelectionBar as any)()
142
+ bar.count = 1
143
+ bar.actions = [{ id: 'delete', title: 'delete', action: () => {}, emphasis: { danger: true } }]
144
+ if (assistant !== undefined) bar.assistant = assistant
145
+ return templates(bar.render()).map(template => template.strings.join(''))
146
+ }
147
+
148
+ it('★ offers 「AI와 작업」 unless told not to - the lists that had it keep it', () => {
149
+ const drawn = drawnWith()
150
+ expect(drawn.some(template => template.includes('<form'))).toBe(true)
151
+ expect(drawn.some(template => template.includes('data-action='))).toBe(true)
152
+ })
153
+
154
+ it('★ assistant=false keeps the picked count and the list\'s own actions, without the request form', () => {
155
+ const drawn = drawnWith(false)
156
+ expect(drawn.some(template => template.includes('<form'))).toBe(false)
157
+ expect(drawn.some(template => template.includes('class="scope"'))).toBe(true)
158
+ expect(drawn.some(template => template.includes('data-action='))).toBe(true)
159
+ })
160
+
161
+ it('shows nothing while no row is picked, whatever assistant says', () => {
162
+ const bar = new (OxAssistantSelectionBar as any)()
163
+ bar.count = 0
164
+ bar.assistant = false
165
+ expect(templates(bar.render())).toEqual([])
166
+ })
167
+ })
@@ -1,6 +1,9 @@
1
+ import '@material/web/icon/icon.js'
2
+
1
3
  import { css, html, LitElement, nothing } from 'lit'
2
4
  import { customElement, property, state } from 'lit/decorators.js'
3
5
 
6
+ import { actionKey, PageAction, pageActionRunner, PageActionStyles, roleOf } from '@operato/context'
4
7
  import { i18next } from '@operato/i18n'
5
8
  import { notify } from '@operato/layout'
6
9
  import { ButtonStyles, FormFieldStyles } from '@operato/styles'
@@ -11,6 +14,13 @@ import { selectedRecords } from '../utils/assistant-selection.js'
11
14
  /** What a grid says when its picks or its rows change - a row's picker, the header's select-all, a new page of rows. */
12
15
  const GRID_EVENTS = ['select-record-change', 'select-all-change', 'list-adopted']
13
16
 
17
+ /**
18
+ * What the list does with the picked rows - the page action contract (`@operato/context`), run by the same runner as
19
+ * the page's bar, so it neither starts twice nor runs while the page saves. `single` asks for exactly one picked row:
20
+ * otherwise it is not pressable and says why.
21
+ */
22
+ export type SelectionAction = PageAction & { single?: boolean }
23
+
14
24
  /** The part of a grid (data-grist `ox-grist`) the bar reads. */
15
25
  export interface SelectionGrid extends HTMLElement {
16
26
  selected?: any[]
@@ -32,6 +42,7 @@ export class OxAssistantSelectionBar extends LitElement {
32
42
  static styles = [
33
43
  ButtonStyles,
34
44
  FormFieldStyles,
45
+ PageActionStyles,
35
46
  css`
36
47
  :host { display:block; }
37
48
  .selection-work { display:flex; flex-wrap:wrap; align-items:center; gap:var(--spacing-medium); padding:var(--spacing-medium) var(--spacing-large); border-bottom:1px solid var(--ui-line); background:var(--ui-panel); }
@@ -39,6 +50,7 @@ export class OxAssistantSelectionBar extends LitElement {
39
50
  form { display:flex; flex:1 1 320px; min-width:0; gap:var(--spacing-small); }
40
51
  input { flex:1; min-width:0; width:100%; }
41
52
  .tools { display:flex; flex-wrap:wrap; gap:var(--spacing-small); }
53
+ .tools button md-icon { --md-icon-size: 16px; }
42
54
  `
43
55
  ]
44
56
 
@@ -51,9 +63,14 @@ export class OxAssistantSelectionBar extends LitElement {
51
63
  /** The last part of that list's read key (`office.contract` → `contract`): each row goes as `<axis>:<uuid>`. */
52
64
  @property() axis = ''
53
65
  /** What the list itself does with the picked rows, before 「AI와 작업」. */
54
- @property({ attribute: false }) actions: { title: string; action: () => any }[] = []
66
+ @property({ attribute: false }) actions: SelectionAction[] = []
55
67
  /** A row in the person's words, when the code and name do not say it (`selectionLabel`). */
56
68
  @property({ attribute: false }) labelOf?: (row: any) => string
69
+ /**
70
+ * Whether the bar offers 「AI와 작업」 - a request sent with the picked rows. A list the assistant does not read (no
71
+ * `list`/`axis`) turns it off and keeps only its own actions on the picked rows.
72
+ */
73
+ @property({ type: Boolean }) assistant = true
57
74
 
58
75
  @state() count = 0
59
76
  @state() request = ''
@@ -71,8 +88,11 @@ export class OxAssistantSelectionBar extends LitElement {
71
88
  this.count = grid?.selected?.length ?? 0
72
89
  }
73
90
 
91
+ private onRunnerChange = () => this.requestUpdate()
92
+
74
93
  connectedCallback() {
75
94
  super.connectedCallback()
95
+ pageActionRunner.addEventListener('change', this.onRunnerChange)
76
96
  this.root = this.getRootNode()
77
97
  for (const event of GRID_EVENTS) this.root.addEventListener(event, this.recount)
78
98
  void this.recount()
@@ -80,6 +100,7 @@ export class OxAssistantSelectionBar extends LitElement {
80
100
 
81
101
  disconnectedCallback() {
82
102
  super.disconnectedCallback()
103
+ pageActionRunner.removeEventListener('change', this.onRunnerChange)
83
104
  for (const event of GRID_EVENTS) this.root?.removeEventListener(event, this.recount)
84
105
  this.root = undefined
85
106
  }
@@ -111,18 +132,46 @@ export class OxAssistantSelectionBar extends LitElement {
111
132
  if (outcome?.sent && this.request === request) this.request = ''
112
133
  }
113
134
 
135
+ /** One action on the picked rows: its icon and name, destructive ones in the error colour, the reason when it cannot run. */
136
+ private renderAction(action: SelectionAction) {
137
+ const reason = action.single && this.count !== 1 ? i18next.t('ai-assistant.selection.pick-one') : action.disabled ? action.disabledTitle : undefined
138
+ const running = pageActionRunner.isRunning(action)
139
+ const blocked = running || pageActionRunner.isHeld(action) || !!reason || !!action.disabled
140
+ return html`<button
141
+ quiet
142
+ small
143
+ data-action=${actionKey(action)}
144
+ ?danger=${roleOf(action) === 'danger'}
145
+ ?running=${running}
146
+ aria-disabled=${blocked ? 'true' : 'false'}
147
+ aria-busy=${running ? 'true' : 'false'}
148
+ title=${reason ?? action.title ?? ''}
149
+ @click=${(event: Event) => {
150
+ if (reason) {
151
+ notify({ message: reason, level: 'info' })
152
+ return
153
+ }
154
+ pageActionRunner.run(action, event)
155
+ }}
156
+ >
157
+ ${running ? html`<md-icon working aria-hidden="true">progress_activity</md-icon>` : action.icon ? html`<md-icon aria-hidden="true">${action.icon}</md-icon>` : nothing}${action.title}
158
+ </button>`
159
+ }
160
+
114
161
  render() {
115
162
  return this.count
116
163
  ? html`<div class="selection-work" role="region" aria-label=${i18next.t('ai-assistant.selection.region')}>
117
164
  <span class="scope" role="status">${i18next.t('ai-assistant.selection.count', { n: this.count })}</span>
118
165
  ${this.actions.length
119
- ? html`<div class="tools">${this.actions.map(action => html`<button quiet small @click=${action.action}>${action.title}</button>`)}</div>`
166
+ ? html`<div class="tools">${this.actions.filter(Boolean).map(action => this.renderAction(action))}</div>`
167
+ : nothing}
168
+ ${this.assistant
169
+ ? html`<form @submit=${(event: Event) => { event.preventDefault(); this.ask() }}>
170
+ <input aria-label=${i18next.t('ai-assistant.selection.request')} placeholder=${i18next.t('ai-assistant.selection.request')}
171
+ .value=${this.request} @input=${(event: any) => (this.request = event.target.value)} />
172
+ <button quiet small type="submit">${i18next.t('ai-assistant.selection.work-with-ai')}</button>
173
+ </form>`
120
174
  : nothing}
121
- <form @submit=${(event: Event) => { event.preventDefault(); this.ask() }}>
122
- <input aria-label=${i18next.t('ai-assistant.selection.request')} placeholder=${i18next.t('ai-assistant.selection.request')}
123
- .value=${this.request} @input=${(event: any) => (this.request = event.target.value)} />
124
- <button quiet small type="submit">${i18next.t('ai-assistant.selection.work-with-ai')}</button>
125
- </form>
126
175
  </div>`
127
176
  : nothing
128
177
  }
@@ -1,4 +1,14 @@
1
+ import '@material/web/icon/icon.js';
1
2
  import { LitElement, nothing } from 'lit';
3
+ import { PageAction } from '@operato/context';
4
+ /**
5
+ * What the list does with the picked rows - the page action contract (`@operato/context`), run by the same runner as
6
+ * the page's bar, so it neither starts twice nor runs while the page saves. `single` asks for exactly one picked row:
7
+ * otherwise it is not pressable and says why.
8
+ */
9
+ export type SelectionAction = PageAction & {
10
+ single?: boolean;
11
+ };
2
12
  /** The part of a grid (data-grist `ox-grist`) the bar reads. */
3
13
  export interface SelectionGrid extends HTMLElement {
4
14
  selected?: any[];
@@ -25,20 +35,25 @@ export declare class OxAssistantSelectionBar extends LitElement {
25
35
  /** The last part of that list's read key (`office.contract` → `contract`): each row goes as `<axis>:<uuid>`. */
26
36
  axis: string;
27
37
  /** What the list itself does with the picked rows, before 「AI와 작업」. */
28
- actions: {
29
- title: string;
30
- action: () => any;
31
- }[];
38
+ actions: SelectionAction[];
32
39
  /** A row in the person's words, when the code and name do not say it (`selectionLabel`). */
33
40
  labelOf?: (row: any) => string;
41
+ /**
42
+ * Whether the bar offers 「AI와 작업」 - a request sent with the picked rows. A list the assistant does not read (no
43
+ * `list`/`axis`) turns it off and keeps only its own actions on the picked rows.
44
+ */
45
+ assistant: boolean;
34
46
  count: number;
35
47
  request: string;
36
48
  private get grid();
37
49
  private root?;
38
50
  private recount;
51
+ private onRunnerChange;
39
52
  connectedCallback(): void;
40
53
  disconnectedCallback(): void;
41
54
  willUpdate(changes: Map<string, unknown>): void;
42
55
  ask(request?: string): Promise<void>;
56
+ /** One action on the picked rows: its icon and name, destructive ones in the error colour, the reason when it cannot run. */
57
+ private renderAction;
43
58
  render(): import("lit-html").TemplateResult<1> | typeof nothing;
44
59
  }
@@ -1,6 +1,8 @@
1
1
  import { __decorate, __metadata } from "tslib";
2
+ import '@material/web/icon/icon.js';
2
3
  import { css, html, LitElement, nothing } from 'lit';
3
4
  import { customElement, property, state } from 'lit/decorators.js';
5
+ import { actionKey, pageActionRunner, PageActionStyles, roleOf } from '@operato/context';
4
6
  import { i18next } from '@operato/i18n';
5
7
  import { notify } from '@operato/layout';
6
8
  import { ButtonStyles, FormFieldStyles } from '@operato/styles';
@@ -29,6 +31,11 @@ let OxAssistantSelectionBar = class OxAssistantSelectionBar extends LitElement {
29
31
  this.axis = '';
30
32
  /** What the list itself does with the picked rows, before 「AI와 작업」. */
31
33
  this.actions = [];
34
+ /**
35
+ * Whether the bar offers 「AI와 작업」 - a request sent with the picked rows. A list the assistant does not read (no
36
+ * `list`/`axis`) turns it off and keeps only its own actions on the picked rows.
37
+ */
38
+ this.assistant = true;
32
39
  this.count = 0;
33
40
  this.request = '';
34
41
  this.recount = async (event) => {
@@ -38,10 +45,12 @@ let OxAssistantSelectionBar = class OxAssistantSelectionBar extends LitElement {
38
45
  await grid?.updateComplete;
39
46
  this.count = grid?.selected?.length ?? 0;
40
47
  };
48
+ this.onRunnerChange = () => this.requestUpdate();
41
49
  }
42
50
  static { this.styles = [
43
51
  ButtonStyles,
44
52
  FormFieldStyles,
53
+ PageActionStyles,
45
54
  css `
46
55
  :host { display:block; }
47
56
  .selection-work { display:flex; flex-wrap:wrap; align-items:center; gap:var(--spacing-medium); padding:var(--spacing-medium) var(--spacing-large); border-bottom:1px solid var(--ui-line); background:var(--ui-panel); }
@@ -49,6 +58,7 @@ let OxAssistantSelectionBar = class OxAssistantSelectionBar extends LitElement {
49
58
  form { display:flex; flex:1 1 320px; min-width:0; gap:var(--spacing-small); }
50
59
  input { flex:1; min-width:0; width:100%; }
51
60
  .tools { display:flex; flex-wrap:wrap; gap:var(--spacing-small); }
61
+ .tools button md-icon { --md-icon-size: 16px; }
52
62
  `
53
63
  ]; }
54
64
  get grid() {
@@ -56,6 +66,7 @@ let OxAssistantSelectionBar = class OxAssistantSelectionBar extends LitElement {
56
66
  }
57
67
  connectedCallback() {
58
68
  super.connectedCallback();
69
+ pageActionRunner.addEventListener('change', this.onRunnerChange);
59
70
  this.root = this.getRootNode();
60
71
  for (const event of GRID_EVENTS)
61
72
  this.root.addEventListener(event, this.recount);
@@ -63,6 +74,7 @@ let OxAssistantSelectionBar = class OxAssistantSelectionBar extends LitElement {
63
74
  }
64
75
  disconnectedCallback() {
65
76
  super.disconnectedCallback();
77
+ pageActionRunner.removeEventListener('change', this.onRunnerChange);
66
78
  for (const event of GRID_EVENTS)
67
79
  this.root?.removeEventListener(event, this.recount);
68
80
  this.root = undefined;
@@ -94,18 +106,45 @@ let OxAssistantSelectionBar = class OxAssistantSelectionBar extends LitElement {
94
106
  if (outcome?.sent && this.request === request)
95
107
  this.request = '';
96
108
  }
109
+ /** One action on the picked rows: its icon and name, destructive ones in the error colour, the reason when it cannot run. */
110
+ renderAction(action) {
111
+ const reason = action.single && this.count !== 1 ? i18next.t('ai-assistant.selection.pick-one') : action.disabled ? action.disabledTitle : undefined;
112
+ const running = pageActionRunner.isRunning(action);
113
+ const blocked = running || pageActionRunner.isHeld(action) || !!reason || !!action.disabled;
114
+ return html `<button
115
+ quiet
116
+ small
117
+ data-action=${actionKey(action)}
118
+ ?danger=${roleOf(action) === 'danger'}
119
+ ?running=${running}
120
+ aria-disabled=${blocked ? 'true' : 'false'}
121
+ aria-busy=${running ? 'true' : 'false'}
122
+ title=${reason ?? action.title ?? ''}
123
+ @click=${(event) => {
124
+ if (reason) {
125
+ notify({ message: reason, level: 'info' });
126
+ return;
127
+ }
128
+ pageActionRunner.run(action, event);
129
+ }}
130
+ >
131
+ ${running ? html `<md-icon working aria-hidden="true">progress_activity</md-icon>` : action.icon ? html `<md-icon aria-hidden="true">${action.icon}</md-icon>` : nothing}${action.title}
132
+ </button>`;
133
+ }
97
134
  render() {
98
135
  return this.count
99
136
  ? html `<div class="selection-work" role="region" aria-label=${i18next.t('ai-assistant.selection.region')}>
100
137
  <span class="scope" role="status">${i18next.t('ai-assistant.selection.count', { n: this.count })}</span>
101
138
  ${this.actions.length
102
- ? html `<div class="tools">${this.actions.map(action => html `<button quiet small @click=${action.action}>${action.title}</button>`)}</div>`
139
+ ? html `<div class="tools">${this.actions.filter(Boolean).map(action => this.renderAction(action))}</div>`
140
+ : nothing}
141
+ ${this.assistant
142
+ ? html `<form @submit=${(event) => { event.preventDefault(); this.ask(); }}>
143
+ <input aria-label=${i18next.t('ai-assistant.selection.request')} placeholder=${i18next.t('ai-assistant.selection.request')}
144
+ .value=${this.request} @input=${(event) => (this.request = event.target.value)} />
145
+ <button quiet small type="submit">${i18next.t('ai-assistant.selection.work-with-ai')}</button>
146
+ </form>`
103
147
  : nothing}
104
- <form @submit=${(event) => { event.preventDefault(); this.ask(); }}>
105
- <input aria-label=${i18next.t('ai-assistant.selection.request')} placeholder=${i18next.t('ai-assistant.selection.request')}
106
- .value=${this.request} @input=${(event) => (this.request = event.target.value)} />
107
- <button quiet small type="submit">${i18next.t('ai-assistant.selection.work-with-ai')}</button>
108
- </form>
109
148
  </div>`
110
149
  : nothing;
111
150
  }
@@ -134,6 +173,10 @@ __decorate([
134
173
  property({ attribute: false }),
135
174
  __metadata("design:type", Function)
136
175
  ], OxAssistantSelectionBar.prototype, "labelOf", void 0);
176
+ __decorate([
177
+ property({ type: Boolean }),
178
+ __metadata("design:type", Object)
179
+ ], OxAssistantSelectionBar.prototype, "assistant", void 0);
137
180
  __decorate([
138
181
  state(),
139
182
  __metadata("design:type", Object)
@@ -1 +1 @@
1
- {"version":3,"file":"assistant-selection-bar.js","sourceRoot":"","sources":["../../client/components/assistant-selection-bar.ts"],"names":[],"mappings":"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","sourcesContent":["import { css, html, LitElement, nothing } from 'lit'\nimport { customElement, property, state } from 'lit/decorators.js'\n\nimport { i18next } from '@operato/i18n'\nimport { notify } from '@operato/layout'\nimport { ButtonStyles, FormFieldStyles } from '@operato/styles'\n\nimport { askAssistant } from '../utils/assistant-ask.js'\nimport { selectedRecords } from '../utils/assistant-selection.js'\n\n/** What a grid says when its picks or its rows change - a row's picker, the header's select-all, a new page of rows. */\nconst GRID_EVENTS = ['select-record-change', 'select-all-change', 'list-adopted']\n\n/** The part of a grid (data-grist `ox-grist`) the bar reads. */\nexport interface SelectionGrid extends HTMLElement {\n selected?: any[]\n updateComplete?: Promise<unknown>\n}\n\n/**\n * 「AI와 작업」 over a list - one bar for every application's lists (design/ai/shared-dock.md §2-11-1). While rows are\n * picked it shows how many, what the list itself does with them, and a request sent to this screen's conversation with\n * the rows as the selection. Begun on the Office ledgers; the user, 2026-10-25: 「'AI와 작업' 기능을 사용하려면, 그리드\n * 선택기능이 필요한데, 주요 리스트 화면에서 선택기능이 없는 경우가 꽤 있다」, and it is to serve plant, warehouse and twin.\n *\n * A screen gives the grid (asked for when needed - a page finds its grid only after its first render), the list as the\n * person knows it, the list query and its axis (the last part of the list's read key, so a row goes as `<axis>:<uuid>`),\n * and, if it likes, its own actions and words for a row. The grid shows `SELECTION_ROW_PICKER`.\n */\n@customElement('ox-assistant-selection-bar')\nexport class OxAssistantSelectionBar extends LitElement {\n static styles = [\n ButtonStyles,\n FormFieldStyles,\n css`\n :host { display:block; }\n .selection-work { display:flex; flex-wrap:wrap; align-items:center; gap:var(--spacing-medium); padding:var(--spacing-medium) var(--spacing-large); border-bottom:1px solid var(--ui-line); background:var(--ui-panel); }\n .scope { font-size:var(--ui-t-body); font-weight:600; white-space:nowrap; }\n form { display:flex; flex:1 1 320px; min-width:0; gap:var(--spacing-small); }\n input { flex:1; min-width:0; width:100%; }\n .tools { display:flex; flex-wrap:wrap; gap:var(--spacing-small); }\n `\n ]\n\n /** The grid whose picked rows these are, asked for when needed. */\n @property({ attribute: false }) gridOf?: () => SelectionGrid | undefined\n /** The list as the person knows it (「계약 대장」). */\n @property() name = ''\n /** The list query the rows came from (`contracts`) - one the assistant reads. */\n @property() list = ''\n /** The last part of that list's read key (`office.contract` → `contract`): each row goes as `<axis>:<uuid>`. */\n @property() axis = ''\n /** What the list itself does with the picked rows, before 「AI와 작업」. */\n @property({ attribute: false }) actions: { title: string; action: () => any }[] = []\n /** A row in the person's words, when the code and name do not say it (`selectionLabel`). */\n @property({ attribute: false }) labelOf?: (row: any) => string\n\n @state() count = 0\n @state() request = ''\n\n private get grid() {\n return this.gridOf?.()\n }\n\n /* The grid's own events reach the page's root (bubbling, composed); the bar counts those that come from its grid. */\n private root?: Node\n private recount = async (event?: Event) => {\n const grid = this.grid\n if (event && (!grid || !event.composedPath().includes(grid))) return\n await grid?.updateComplete\n this.count = grid?.selected?.length ?? 0\n }\n\n connectedCallback() {\n super.connectedCallback()\n this.root = this.getRootNode()\n for (const event of GRID_EVENTS) this.root.addEventListener(event, this.recount)\n void this.recount()\n }\n\n disconnectedCallback() {\n super.disconnectedCallback()\n for (const event of GRID_EVENTS) this.root?.removeEventListener(event, this.recount)\n this.root = undefined\n }\n\n willUpdate(changes: Map<string, unknown>) {\n if (changes.has('gridOf')) void this.recount()\n }\n\n /*\n * A screen request (design/ai/shared-dock.md §2-11-1, the user: 「\"AI와 작업\" 기능이 unstable 하다 … 새로운 세션을 여는지,\n * 기존 세션을 활용하는지 등등을 명확하게 하라」): it goes to this screen's conversation with the rows picked now, in\n * screen order, and is sent at once - what was written on the bar, or the set request. The bar is cleared only when it\n * was sent; otherwise the dock has said why and the words stay to press again.\n */\n async ask(request: string = this.request) {\n const rows = this.grid?.selected ?? []\n if (!rows.length) {\n notify({ message: i18next.t('ai-assistant.selection.pick-rows'), level: 'info' })\n return\n }\n if (rows.some(row => !row.id || row.__dirty__)) {\n notify({ message: i18next.t('ai-assistant.selection.save-first'), level: 'warn' })\n return\n }\n const outcome = await askAssistant({\n selection: { title: this.name, list: this.list, records: selectedRecords(rows, { axis: this.axis, labelOf: this.labelOf }) },\n ask: request?.trim() || i18next.t('ai-assistant.selection.ask', { n: rows.length, title: this.name })\n })\n if (outcome?.sent && this.request === request) this.request = ''\n }\n\n render() {\n return this.count\n ? html`<div class=\"selection-work\" role=\"region\" aria-label=${i18next.t('ai-assistant.selection.region')}>\n <span class=\"scope\" role=\"status\">${i18next.t('ai-assistant.selection.count', { n: this.count })}</span>\n ${this.actions.length\n ? html`<div class=\"tools\">${this.actions.map(action => html`<button quiet small @click=${action.action}>${action.title}</button>`)}</div>`\n : nothing}\n <form @submit=${(event: Event) => { event.preventDefault(); this.ask() }}>\n <input aria-label=${i18next.t('ai-assistant.selection.request')} placeholder=${i18next.t('ai-assistant.selection.request')}\n .value=${this.request} @input=${(event: any) => (this.request = event.target.value)} />\n <button quiet small type=\"submit\">${i18next.t('ai-assistant.selection.work-with-ai')}</button>\n </form>\n </div>`\n : nothing\n }\n}\n"]}
1
+ {"version":3,"file":"assistant-selection-bar.js","sourceRoot":"","sources":["../../client/components/assistant-selection-bar.ts"],"names":[],"mappings":"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","sourcesContent":["import '@material/web/icon/icon.js'\n\nimport { css, html, LitElement, nothing } from 'lit'\nimport { customElement, property, state } from 'lit/decorators.js'\n\nimport { actionKey, PageAction, pageActionRunner, PageActionStyles, roleOf } from '@operato/context'\nimport { i18next } from '@operato/i18n'\nimport { notify } from '@operato/layout'\nimport { ButtonStyles, FormFieldStyles } from '@operato/styles'\n\nimport { askAssistant } from '../utils/assistant-ask.js'\nimport { selectedRecords } from '../utils/assistant-selection.js'\n\n/** What a grid says when its picks or its rows change - a row's picker, the header's select-all, a new page of rows. */\nconst GRID_EVENTS = ['select-record-change', 'select-all-change', 'list-adopted']\n\n/**\n * What the list does with the picked rows - the page action contract (`@operato/context`), run by the same runner as\n * the page's bar, so it neither starts twice nor runs while the page saves. `single` asks for exactly one picked row:\n * otherwise it is not pressable and says why.\n */\nexport type SelectionAction = PageAction & { single?: boolean }\n\n/** The part of a grid (data-grist `ox-grist`) the bar reads. */\nexport interface SelectionGrid extends HTMLElement {\n selected?: any[]\n updateComplete?: Promise<unknown>\n}\n\n/**\n * 「AI와 작업」 over a list - one bar for every application's lists (design/ai/shared-dock.md §2-11-1). While rows are\n * picked it shows how many, what the list itself does with them, and a request sent to this screen's conversation with\n * the rows as the selection. Begun on the Office ledgers; the user, 2026-10-25: 「'AI와 작업' 기능을 사용하려면, 그리드\n * 선택기능이 필요한데, 주요 리스트 화면에서 선택기능이 없는 경우가 꽤 있다」, and it is to serve plant, warehouse and twin.\n *\n * A screen gives the grid (asked for when needed - a page finds its grid only after its first render), the list as the\n * person knows it, the list query and its axis (the last part of the list's read key, so a row goes as `<axis>:<uuid>`),\n * and, if it likes, its own actions and words for a row. The grid shows `SELECTION_ROW_PICKER`.\n */\n@customElement('ox-assistant-selection-bar')\nexport class OxAssistantSelectionBar extends LitElement {\n static styles = [\n ButtonStyles,\n FormFieldStyles,\n PageActionStyles,\n css`\n :host { display:block; }\n .selection-work { display:flex; flex-wrap:wrap; align-items:center; gap:var(--spacing-medium); padding:var(--spacing-medium) var(--spacing-large); border-bottom:1px solid var(--ui-line); background:var(--ui-panel); }\n .scope { font-size:var(--ui-t-body); font-weight:600; white-space:nowrap; }\n form { display:flex; flex:1 1 320px; min-width:0; gap:var(--spacing-small); }\n input { flex:1; min-width:0; width:100%; }\n .tools { display:flex; flex-wrap:wrap; gap:var(--spacing-small); }\n .tools button md-icon { --md-icon-size: 16px; }\n `\n ]\n\n /** The grid whose picked rows these are, asked for when needed. */\n @property({ attribute: false }) gridOf?: () => SelectionGrid | undefined\n /** The list as the person knows it (「계약 대장」). */\n @property() name = ''\n /** The list query the rows came from (`contracts`) - one the assistant reads. */\n @property() list = ''\n /** The last part of that list's read key (`office.contract` → `contract`): each row goes as `<axis>:<uuid>`. */\n @property() axis = ''\n /** What the list itself does with the picked rows, before 「AI와 작업」. */\n @property({ attribute: false }) actions: SelectionAction[] = []\n /** A row in the person's words, when the code and name do not say it (`selectionLabel`). */\n @property({ attribute: false }) labelOf?: (row: any) => string\n /**\n * Whether the bar offers 「AI와 작업」 - a request sent with the picked rows. A list the assistant does not read (no\n * `list`/`axis`) turns it off and keeps only its own actions on the picked rows.\n */\n @property({ type: Boolean }) assistant = true\n\n @state() count = 0\n @state() request = ''\n\n private get grid() {\n return this.gridOf?.()\n }\n\n /* The grid's own events reach the page's root (bubbling, composed); the bar counts those that come from its grid. */\n private root?: Node\n private recount = async (event?: Event) => {\n const grid = this.grid\n if (event && (!grid || !event.composedPath().includes(grid))) return\n await grid?.updateComplete\n this.count = grid?.selected?.length ?? 0\n }\n\n private onRunnerChange = () => this.requestUpdate()\n\n connectedCallback() {\n super.connectedCallback()\n pageActionRunner.addEventListener('change', this.onRunnerChange)\n this.root = this.getRootNode()\n for (const event of GRID_EVENTS) this.root.addEventListener(event, this.recount)\n void this.recount()\n }\n\n disconnectedCallback() {\n super.disconnectedCallback()\n pageActionRunner.removeEventListener('change', this.onRunnerChange)\n for (const event of GRID_EVENTS) this.root?.removeEventListener(event, this.recount)\n this.root = undefined\n }\n\n willUpdate(changes: Map<string, unknown>) {\n if (changes.has('gridOf')) void this.recount()\n }\n\n /*\n * A screen request (design/ai/shared-dock.md §2-11-1, the user: 「\"AI와 작업\" 기능이 unstable 하다 … 새로운 세션을 여는지,\n * 기존 세션을 활용하는지 등등을 명확하게 하라」): it goes to this screen's conversation with the rows picked now, in\n * screen order, and is sent at once - what was written on the bar, or the set request. The bar is cleared only when it\n * was sent; otherwise the dock has said why and the words stay to press again.\n */\n async ask(request: string = this.request) {\n const rows = this.grid?.selected ?? []\n if (!rows.length) {\n notify({ message: i18next.t('ai-assistant.selection.pick-rows'), level: 'info' })\n return\n }\n if (rows.some(row => !row.id || row.__dirty__)) {\n notify({ message: i18next.t('ai-assistant.selection.save-first'), level: 'warn' })\n return\n }\n const outcome = await askAssistant({\n selection: { title: this.name, list: this.list, records: selectedRecords(rows, { axis: this.axis, labelOf: this.labelOf }) },\n ask: request?.trim() || i18next.t('ai-assistant.selection.ask', { n: rows.length, title: this.name })\n })\n if (outcome?.sent && this.request === request) this.request = ''\n }\n\n /** One action on the picked rows: its icon and name, destructive ones in the error colour, the reason when it cannot run. */\n private renderAction(action: SelectionAction) {\n const reason = action.single && this.count !== 1 ? i18next.t('ai-assistant.selection.pick-one') : action.disabled ? action.disabledTitle : undefined\n const running = pageActionRunner.isRunning(action)\n const blocked = running || pageActionRunner.isHeld(action) || !!reason || !!action.disabled\n return html`<button\n quiet\n small\n data-action=${actionKey(action)}\n ?danger=${roleOf(action) === 'danger'}\n ?running=${running}\n aria-disabled=${blocked ? 'true' : 'false'}\n aria-busy=${running ? 'true' : 'false'}\n title=${reason ?? action.title ?? ''}\n @click=${(event: Event) => {\n if (reason) {\n notify({ message: reason, level: 'info' })\n return\n }\n pageActionRunner.run(action, event)\n }}\n >\n ${running ? html`<md-icon working aria-hidden=\"true\">progress_activity</md-icon>` : action.icon ? html`<md-icon aria-hidden=\"true\">${action.icon}</md-icon>` : nothing}${action.title}\n </button>`\n }\n\n render() {\n return this.count\n ? html`<div class=\"selection-work\" role=\"region\" aria-label=${i18next.t('ai-assistant.selection.region')}>\n <span class=\"scope\" role=\"status\">${i18next.t('ai-assistant.selection.count', { n: this.count })}</span>\n ${this.actions.length\n ? html`<div class=\"tools\">${this.actions.filter(Boolean).map(action => this.renderAction(action))}</div>`\n : nothing}\n ${this.assistant\n ? html`<form @submit=${(event: Event) => { event.preventDefault(); this.ask() }}>\n <input aria-label=${i18next.t('ai-assistant.selection.request')} placeholder=${i18next.t('ai-assistant.selection.request')}\n .value=${this.request} @input=${(event: any) => (this.request = event.target.value)} />\n <button quiet small type=\"submit\">${i18next.t('ai-assistant.selection.work-with-ai')}</button>\n </form>`\n : nothing}\n </div>`\n : nothing\n }\n}\n"]}
@@ -5,6 +5,52 @@
5
5
  */
6
6
  import { readFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
+ /*
9
+ * The bar's actions are run here for real: the page action runner is the published one (`@operato/context`, loaded by
10
+ * node itself - jest does not transform node_modules), and the bar's template is read as lit would get it. lit,
11
+ * the icons and the words are stood in for, since this suite runs without a DOM.
12
+ */
13
+ jest.mock('lit', () => ({
14
+ html: (strings, ...values) => ({ strings, values }),
15
+ css: () => '',
16
+ nothing: Symbol.for('lit-nothing'),
17
+ LitElement: class {
18
+ requestUpdate() { }
19
+ }
20
+ }));
21
+ jest.mock('lit/decorators.js', () => ({ customElement: () => (it) => it, property: () => (it) => it, state: () => (it) => it }));
22
+ jest.mock('@material/web/icon/icon.js', () => ({}), { virtual: true });
23
+ jest.mock('@operato/context', () => {
24
+ /* The published runner itself: its one file has no imports, so it is turned into CommonJS here and evaluated. */
25
+ const { readFileSync } = jest.requireActual('node:fs');
26
+ const { dirname, join } = jest.requireActual('node:path');
27
+ const { transformSync } = jest.requireActual('@babel/core');
28
+ const file = join(dirname(require.resolve('@operato/context/package.json')), 'dist/src/tools/page-action.js');
29
+ const { code } = transformSync(readFileSync(file, 'utf-8'), { babelrc: false, configFile: false, plugins: ['@babel/plugin-transform-modules-commonjs'] });
30
+ const module = { exports: {} };
31
+ new Function('module', 'exports', code)(module, module.exports);
32
+ return { ...module.exports, PageActionStyles: '' };
33
+ });
34
+ jest.mock('@operato/i18n', () => ({ i18next: { t: (key) => key } }));
35
+ jest.mock('@operato/layout', () => ({ notify: jest.fn() }));
36
+ jest.mock('@operato/styles', () => ({ ButtonStyles: '', FormFieldStyles: '' }));
37
+ jest.mock('../utils/assistant-ask', () => ({ askAssistant: jest.fn() }));
38
+ jest.mock('../utils/assistant-selection', () => ({ selectedRecords: jest.fn() }));
39
+ import { notify } from '@operato/layout';
40
+ import { OxAssistantSelectionBar } from './assistant-selection-bar';
41
+ /** The value bound to `name=` in the bar's button template (`?danger`, `aria-disabled`, `title`, `@click`). */
42
+ const bound = (drawn, name) => {
43
+ const at = drawn.strings.findIndex(part => /\s$/.test(` ${part.slice(0, part.length - name.length - 1)}`) && part.endsWith(`${name}=`));
44
+ if (at < 0)
45
+ throw new Error(`no ${name} in the template`);
46
+ return drawn.values[at];
47
+ };
48
+ const barWith = (count) => {
49
+ /* `renderAction` is private to the bar; the suite reaches it as the template lit would be handed. */
50
+ const bar = new OxAssistantSelectionBar();
51
+ bar.count = count;
52
+ return bar;
53
+ };
8
54
  const bare = readFileSync(join(__dirname, 'assistant-selection-bar.ts'), 'utf-8').replace(/\/\*[\s\S]*?\*\//g, '');
9
55
  describe('the selection bar', () => {
10
56
  it('★ counts every way a grid picks - a row, the header\'s select-all, a new page of rows', () => {
@@ -24,7 +70,7 @@ describe('the selection bar', () => {
24
70
  });
25
71
  it('every word it shows reads in every language', () => {
26
72
  const keys = [...new Set([...bare.matchAll(/'(ai-assistant\.selection\.[\w-]+)'/g)].map(([, key]) => key))];
27
- expect(keys.length).toBe(7);
73
+ expect(keys.length).toBe(8);
28
74
  for (const locale of ['en', 'ko', 'ja', 'zh', 'ms', 'es']) {
29
75
  const words = JSON.parse(readFileSync(join(__dirname, `../../translations/${locale}.json`), 'utf-8'));
30
76
  for (const key of keys)
@@ -35,4 +81,68 @@ describe('the selection bar', () => {
35
81
  }
36
82
  });
37
83
  });
84
+ describe('the selection bar\'s actions', () => {
85
+ beforeEach(() => notify.mockClear());
86
+ it('★ draws a destructive action as danger, an ordinary one not', () => {
87
+ const bar = barWith(1);
88
+ expect(bound(bar.renderAction({ title: 'delete', action: () => { }, emphasis: { danger: true } }), '?danger')).toBe(true);
89
+ expect(bound(bar.renderAction({ title: 'copy', action: () => { } }), '?danger')).toBe(false);
90
+ });
91
+ it('★ a one-row action with two rows picked does not run - it is aria-disabled and says why', async () => {
92
+ const bar = barWith(2);
93
+ const action = jest.fn();
94
+ const drawn = bar.renderAction({ title: 'open', single: true, action });
95
+ expect(bound(drawn, 'aria-disabled')).toBe('true');
96
+ expect(bound(drawn, 'title')).toBe('ai-assistant.selection.pick-one');
97
+ await bound(drawn, '@click')({});
98
+ expect(action).not.toHaveBeenCalled();
99
+ expect(notify).toHaveBeenCalledWith({ message: 'ai-assistant.selection.pick-one', level: 'info' });
100
+ const one = barWith(1).renderAction({ title: 'open', single: true, action });
101
+ expect(bound(one, 'aria-disabled')).toBe('false');
102
+ });
103
+ it('★ pressed twice while its first run is out, it runs once', async () => {
104
+ const bar = barWith(1);
105
+ let finish;
106
+ const action = jest.fn(() => new Promise(resolve => (finish = resolve)));
107
+ const declared = { id: 'twice', title: 'send', single: true, action };
108
+ const first = bound(bar.renderAction(declared), '@click')({});
109
+ const second = bound(bar.renderAction(declared), '@click')({});
110
+ expect(bound(bar.renderAction(declared), 'aria-disabled')).toBe('true');
111
+ finish();
112
+ await Promise.all([first, second]);
113
+ await new Promise(resolve => setTimeout(resolve, 0));
114
+ expect(action).toHaveBeenCalledTimes(1);
115
+ });
116
+ });
117
+ describe('the selection bar without the assistant', () => {
118
+ /** Every template drawn under `drawn`, the outer one first - lit's nested `html` results as the mock hands them. */
119
+ const templates = (drawn) => drawn && typeof drawn === 'object' && Array.isArray(drawn.strings)
120
+ ? [drawn, ...drawn.values.flatMap((value) => (Array.isArray(value) ? value.flatMap(templates) : templates(value)))]
121
+ : [];
122
+ const drawnWith = (assistant) => {
123
+ const bar = new OxAssistantSelectionBar();
124
+ bar.count = 1;
125
+ bar.actions = [{ id: 'delete', title: 'delete', action: () => { }, emphasis: { danger: true } }];
126
+ if (assistant !== undefined)
127
+ bar.assistant = assistant;
128
+ return templates(bar.render()).map(template => template.strings.join(''));
129
+ };
130
+ it('★ offers 「AI와 작업」 unless told not to - the lists that had it keep it', () => {
131
+ const drawn = drawnWith();
132
+ expect(drawn.some(template => template.includes('<form'))).toBe(true);
133
+ expect(drawn.some(template => template.includes('data-action='))).toBe(true);
134
+ });
135
+ it('★ assistant=false keeps the picked count and the list\'s own actions, without the request form', () => {
136
+ const drawn = drawnWith(false);
137
+ expect(drawn.some(template => template.includes('<form'))).toBe(false);
138
+ expect(drawn.some(template => template.includes('class="scope"'))).toBe(true);
139
+ expect(drawn.some(template => template.includes('data-action='))).toBe(true);
140
+ });
141
+ it('shows nothing while no row is picked, whatever assistant says', () => {
142
+ const bar = new OxAssistantSelectionBar();
143
+ bar.count = 0;
144
+ bar.assistant = false;
145
+ expect(templates(bar.render())).toEqual([]);
146
+ });
147
+ });
38
148
  //# sourceMappingURL=assistant-selection-bar.test.js.map