@slates/provider 1.0.0-rc.34 → 1.0.0-rc.35

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,94 @@
1
+ import { z } from 'zod';
2
+ import type { SlateContext } from '../context';
3
+ import { SlateDeclarationError } from '../error';
4
+ import type { SlateSpecification } from '../specification/specification';
5
+
6
+ export interface SlateOption {
7
+ value: string | number;
8
+ label: string;
9
+ description?: string;
10
+ }
11
+
12
+ export interface SlateOptionListPage {
13
+ options: SlateOption[];
14
+ nextPageToken?: any | null;
15
+ }
16
+
17
+ export type SlateOptionListHandler<
18
+ ConfigType extends {},
19
+ AuthType extends {},
20
+ ParamsType extends {} = {}
21
+ > = (
22
+ context: SlateContext<
23
+ ConfigType,
24
+ AuthType,
25
+ { query: string | null; pageToken: any | null; params: ParamsType }
26
+ >
27
+ ) => Promise<SlateOptionListPage>;
28
+
29
+ export interface SlateOptionListParameters<
30
+ ConfigType extends {},
31
+ AuthType extends {},
32
+ ParamsSchema extends z.ZodObject<any> = z.ZodObject<{}>
33
+ > {
34
+ key: string;
35
+ /** Made strict when declared, so params with a key the schema does not name are rejected. */
36
+ paramsSchema?: ParamsSchema;
37
+ list: SlateOptionListHandler<ConfigType, AuthType, z.output<ParamsSchema>>;
38
+ }
39
+
40
+ /** A list of options other integrations can link their selection fields to. */
41
+ export class SlateOptionList<
42
+ ConfigType extends {},
43
+ AuthType extends {},
44
+ ParamsSchema extends z.ZodObject<any> = z.ZodObject<{}>
45
+ > {
46
+ private constructor(
47
+ private readonly _spec: SlateSpecification<ConfigType, AuthType>,
48
+ private readonly _params: SlateOptionListParameters<ConfigType, AuthType, ParamsSchema>,
49
+ private readonly _paramsSchema: z.ZodObject<any>
50
+ ) {}
51
+
52
+ static create<
53
+ ConfigType extends {},
54
+ AuthType extends {},
55
+ ParamsSchema extends z.ZodObject<any> = z.ZodObject<{}>
56
+ >(
57
+ spec: SlateSpecification<ConfigType, AuthType>,
58
+ params: SlateOptionListParameters<ConfigType, AuthType, ParamsSchema>
59
+ ) {
60
+ let paramsSchema: z.ZodObject<any> = params.paramsSchema ?? z.object({});
61
+ if (paramsSchema?._zod?.def?.type !== 'object') {
62
+ throw new SlateDeclarationError(
63
+ `Option list "${params.key}" params schema must be a zod object`
64
+ );
65
+ }
66
+
67
+ return new SlateOptionList(spec, params, paramsSchema.strict());
68
+ }
69
+
70
+ get spec() {
71
+ return this._spec;
72
+ }
73
+
74
+ get key() {
75
+ return this._params.key;
76
+ }
77
+
78
+ get paramsSchema() {
79
+ return this._paramsSchema;
80
+ }
81
+
82
+ get list() {
83
+ return this._params.list;
84
+ }
85
+ }
86
+
87
+ export let optionList = <
88
+ ConfigType extends {},
89
+ AuthType extends {},
90
+ ParamsSchema extends z.ZodObject<any> = z.ZodObject<{}>
91
+ >(
92
+ spec: SlateSpecification<ConfigType, AuthType>,
93
+ params: SlateOptionListParameters<ConfigType, AuthType, ParamsSchema>
94
+ ) => SlateOptionList.create(spec, params);
@@ -0,0 +1,132 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { z } from 'zod';
3
+ import { SlateAuth } from '../auth';
4
+ import { SlateConfig } from '../config';
5
+ import { SlateSpecification } from '../specification/specification';
6
+ import { SlateTool } from './tool';
7
+ import { SlateTrigger } from './trigger';
8
+ import { triggerGroup } from './triggerGroup';
9
+
10
+ let spec = SlateSpecification.create({
11
+ key: 'demo',
12
+ name: 'Demo',
13
+ config: SlateConfig.create(z.object({})),
14
+ auth: SlateAuth.create<{}>().output(z.object({}))
15
+ });
16
+
17
+ let group = triggerGroup(spec, { key: 'group', name: 'Group' })
18
+ .polling({ pollEvents: async () => ({ events: [] }) })
19
+ .routingMatchers(async () => [])
20
+ .build();
21
+
22
+ let output = z.object({
23
+ fileId: z.string(),
24
+ parents: z.array(z.string()),
25
+ size: z.number().optional()
26
+ });
27
+
28
+ let create = () =>
29
+ SlateTrigger.create(spec, { key: 'changed', name: 'Changed' })
30
+ .input(z.object({}))
31
+ .triggerGroup(group)
32
+ .matches(() => true)
33
+ .map(async () => ({
34
+ type: 'file.changed',
35
+ id: '1',
36
+ output: { fileId: 'a', parents: [] }
37
+ }));
38
+
39
+ describe('trigger event attributes', () => {
40
+ it('exposes the declared fields on the built trigger', () => {
41
+ let trigger = create().output(output).eventAttributes(['fileId', 'parents']).build();
42
+
43
+ expect(trigger.eventAttributes).toEqual(['fileId', 'parents']);
44
+ });
45
+
46
+ it('is empty when none are declared', () => {
47
+ expect(create().output(output).build().eventAttributes).toEqual([]);
48
+ });
49
+
50
+ it('checks the fields against an output schema set after the declaration', () => {
51
+ expect(
52
+ create()
53
+ .eventAttributes(['fileId'] as never[])
54
+ .output(output)
55
+ .build().eventAttributes
56
+ ).toEqual(['fileId']);
57
+
58
+ expect(() =>
59
+ create()
60
+ .eventAttributes(['folderId'] as never[])
61
+ .output(output)
62
+ .build()
63
+ ).toThrow('Event attribute "folderId" is not a top-level property of the output schema');
64
+ });
65
+
66
+ it('rejects fields that are not top-level output properties', () => {
67
+ expect(() =>
68
+ create()
69
+ .output(output)
70
+ .eventAttributes(['folderId' as any])
71
+ ).toThrow('Event attribute "folderId" is not a top-level property of the output schema');
72
+ });
73
+
74
+ it('requires the output schema to be a zod object', () => {
75
+ expect(() =>
76
+ create()
77
+ .output(z.record(z.string(), z.string()) as unknown as z.ZodType<{ fileId: string }>)
78
+ .eventAttributes(['fileId'])
79
+ ).toThrow('Event attributes require the output schema to be a zod object');
80
+
81
+ expect(() =>
82
+ create()
83
+ .eventAttributes(['fileId'] as never[])
84
+ .output(z.record(z.string(), z.string()) as unknown as z.ZodType<{ fileId: string }>)
85
+ .build()
86
+ ).toThrow('Event attributes require the output schema to be a zod object');
87
+ });
88
+
89
+ it('rejects names that do not match the key pattern', () => {
90
+ let schema = z.object({ _id: z.string(), '1st': z.string(), 'a-b': z.string() });
91
+ for (let field of ['_id', '1st', 'a-b']) {
92
+ expect(() =>
93
+ create()
94
+ .output(schema)
95
+ .eventAttributes([field as any])
96
+ ).toThrow(`Event attribute "${field}" must start with a letter`);
97
+ }
98
+
99
+ let long = 'a'.repeat(65);
100
+ expect(() => create().eventAttributes([long as never])).toThrow(
101
+ `Event attribute "${long}" must start with a letter`
102
+ );
103
+ });
104
+
105
+ it('rejects duplicates', () => {
106
+ expect(() => create().output(output).eventAttributes(['fileId', 'fileId'])).toThrow(
107
+ 'Event attribute "fileId" is declared more than once'
108
+ );
109
+ });
110
+
111
+ it('allows at most eight fields', () => {
112
+ let fields = Array.from({ length: 9 }, (_, i) => `field${i}`);
113
+ let schema = z.object(Object.fromEntries(fields.map(field => [field, z.string()])));
114
+
115
+ expect(
116
+ create().output(schema).eventAttributes(fields.slice(0, 8)).build().eventAttributes
117
+ ).toHaveLength(8);
118
+ expect(() => create().output(schema).eventAttributes(fields)).toThrow(
119
+ 'A trigger can declare at most 8 event attributes'
120
+ );
121
+ });
122
+
123
+ it('can only be declared on triggers', () => {
124
+ let tool = SlateTool.create(spec, { key: 'send', name: 'Send' })
125
+ .input(z.object({}))
126
+ .output(z.object({ id: z.string() }));
127
+
128
+ expect(() => tool.eventAttributes(['id'])).toThrow(
129
+ 'eventAttributes can only be set for trigger actions'
130
+ );
131
+ });
132
+ });
@@ -21,6 +21,7 @@ export interface SlateTriggerParameters<
21
21
  triggerGroup: SlateTriggerGroup<ConfigType, AuthType>;
22
22
  matches: SlateTriggerMatchesHandler;
23
23
  map: SlateTriggerMappingHandler<ConfigType, AuthType, InputType, OutputType>;
24
+ eventAttributes?: string[];
24
25
  }
25
26
 
26
27
  export class SlateTrigger<
@@ -34,6 +35,7 @@ export class SlateTrigger<
34
35
  #triggerGroup: SlateTriggerGroup<ConfigType, AuthType>;
35
36
  #matches: SlateTriggerMatchesHandler;
36
37
  #map: SlateTriggerMappingHandler<ConfigType, AuthType, InputType, OutputType>;
38
+ #eventAttributes: string[];
37
39
 
38
40
  private constructor(
39
41
  spec: SlateSpecification<ConfigType, AuthType>,
@@ -46,6 +48,7 @@ export class SlateTrigger<
46
48
  this.#triggerGroup = params.triggerGroup;
47
49
  this.#matches = params.matches;
48
50
  this.#map = params.map;
51
+ this.#eventAttributes = params.eventAttributes ?? [];
49
52
  }
50
53
 
51
54
  static create<ConfigType extends {}, AuthType extends {}>(
@@ -69,6 +72,10 @@ export class SlateTrigger<
69
72
  get map() {
70
73
  return this.#map;
71
74
  }
75
+
76
+ get eventAttributes(): string[] {
77
+ return [...this.#eventAttributes];
78
+ }
72
79
  }
73
80
 
74
81
  export let trigger = <ConfigType extends {}, AuthType extends {}>(
@@ -88,3 +88,85 @@ describe('webhook trigger group declaration', () => {
88
88
  ).toThrow('A webhook delivery config requires an authenticate handler');
89
89
  });
90
90
  });
91
+
92
+ describe('trigger group selection options', () => {
93
+ let polling = (
94
+ selectionSchema: z.ZodType<Record<string, any>> = z.object({
95
+ fileId: z.string(),
96
+ tab: z.string().optional(),
97
+ fileIds: z.array(z.string()),
98
+ folder: z.object({ id: z.string() })
99
+ })
100
+ ) =>
101
+ triggerGroup(spec, { key: 'group', name: 'Group', selectionSchema })
102
+ .polling({ pollEvents: async () => ({ events: [] }) })
103
+ .routingMatchers(async () => []);
104
+ let list = async () => ({ options: [{ value: 'a', label: 'A' }] });
105
+ let linked = { slate: 'acme/files', optionList: 'files', params: { kind: 'sheet' } };
106
+
107
+ it('rejects fields that are not top-level properties of the selection schema', () => {
108
+ for (let field of ['fileid', 'folder.id', 'id', 'constructor']) {
109
+ expect(() => polling().selectionOptions({ [field]: { list } })).toThrow(
110
+ `Selection field "${field}" is not a top-level property of the selection schema`
111
+ );
112
+ }
113
+ });
114
+
115
+ it('rejects a selection schema that is not a zod object', () => {
116
+ for (let selectionSchema of [
117
+ z.record(z.string(), z.string()),
118
+ z.object({ fileId: z.string() }).optional() as z.ZodType<Record<string, any>>
119
+ ]) {
120
+ expect(() => polling(selectionSchema).selectionOptions({ fileId: { list } })).toThrow(
121
+ 'Selection options require the selection schema to be a zod object'
122
+ );
123
+ }
124
+ expect(() =>
125
+ triggerGroup(spec, { key: 'group', name: 'Group' }).selectionOptions({
126
+ fileId: { list }
127
+ })
128
+ ).toThrow('Selection options require a selection schema');
129
+ });
130
+
131
+ it('rejects a bare handler instead of { list, linked }', () => {
132
+ for (let declaration of [list, null]) {
133
+ expect(() => polling().selectionOptions({ fileId: declaration as any })).toThrow(
134
+ 'Selection field "fileId" must be declared as { list, linked }'
135
+ );
136
+ }
137
+ });
138
+
139
+ it('accepts an own list, a linked list, or both per field', () => {
140
+ let group = polling()
141
+ .selectionOptions({ fileId: { list, linked } })
142
+ .selectionOptions({
143
+ tab: { linked: { slate: 'acme/files', optionList: 'tabs' } },
144
+ fileIds: { list }
145
+ })
146
+ .build();
147
+ expect(group.selectionOptions).toEqual({
148
+ fileId: { list, linked },
149
+ tab: { linked: { slate: 'acme/files', optionList: 'tabs' } },
150
+ fileIds: { list }
151
+ });
152
+ });
153
+
154
+ it('rejects a field without an own list or a linked list', () => {
155
+ expect(() => polling().selectionOptions({ fileId: {} })).toThrow(
156
+ 'Selection field "fileId" needs its own options list, a linked option list, or both'
157
+ );
158
+ });
159
+
160
+ it('rejects linked lists that do not name a scoped slate and a list', () => {
161
+ expect(() =>
162
+ polling().selectionOptions({
163
+ fileId: { linked: { slate: 'files', optionList: 'files' } }
164
+ })
165
+ ).toThrow('must be written as <scope>/<name>');
166
+ expect(() =>
167
+ polling().selectionOptions({
168
+ fileId: { linked: { slate: 'acme/files', optionList: ' ' } }
169
+ })
170
+ ).toThrow('Selection field "fileId" must name the option list it links to');
171
+ });
172
+ });
@@ -3,6 +3,7 @@ import type { SlateContext } from '../context';
3
3
  import { SlateDeclarationError } from '../error';
4
4
  import type { SlateSpecification } from '../specification/specification';
5
5
  import type { SlateWebhookHttpResponseInit } from './action';
6
+ import type { SlateOptionListPage } from './optionList';
6
7
 
7
8
  export let SlateDefaultPollingIntervalSeconds = 60 * 10;
8
9
 
@@ -26,6 +27,80 @@ export type SlateTriggerGroupRoutingMatchersHandler<
26
27
  AuthType extends {}
27
28
  > = (context: SlateContext<ConfigType, AuthType, {}>) => Promise<SlateTriggerRoutingMatcher[]>;
28
29
 
30
+ /** Lists the values an account can pick for one selection field. `selection` holds what is entered so far and may be incomplete. */
31
+ export type SlateTriggerGroupSelectionOptionsHandler<
32
+ ConfigType extends {},
33
+ AuthType extends {}
34
+ > = (
35
+ context: SlateContext<
36
+ ConfigType,
37
+ AuthType,
38
+ { query: string | null; pageToken: any | null; selection: SlateTriggerGroupSelection }
39
+ >
40
+ ) => Promise<SlateOptionListPage>;
41
+
42
+ /** An option list exported by another integration, run with that integration's connection for the same account. */
43
+ export interface SlateLinkedOptionList {
44
+ /** Registry name of the integration, as `<scope>/<name>`. */
45
+ slate: string;
46
+ optionList: string;
47
+ params?: Record<string, any>;
48
+ }
49
+
50
+ export interface SlateTriggerGroupSelectionOptionsSources<
51
+ ConfigType extends {},
52
+ AuthType extends {}
53
+ > {
54
+ list?: SlateTriggerGroupSelectionOptionsHandler<ConfigType, AuthType>;
55
+ linked?: SlateLinkedOptionList;
56
+ }
57
+
58
+ let LINKED_SLATE_PATTERN = /^[^/\s]+\/[^/\s]+$/;
59
+
60
+ let checkSelectionOptions = <ConfigType extends {}, AuthType extends {}>(
61
+ field: string,
62
+ sources: SlateTriggerGroupSelectionOptionsSources<ConfigType, AuthType>,
63
+ shape: Record<string, unknown>
64
+ ): SlateTriggerGroupSelectionOptionsSources<ConfigType, AuthType> => {
65
+ if (!Object.hasOwn(shape, field)) {
66
+ throw new SlateDeclarationError(
67
+ `Selection field "${field}" is not a top-level property of the selection schema`
68
+ );
69
+ }
70
+ if (typeof sources !== 'object' || sources === null) {
71
+ throw new SlateDeclarationError(
72
+ `Selection field "${field}" must be declared as { list, linked }`
73
+ );
74
+ }
75
+
76
+ let { list, linked } = sources;
77
+ if (!list && !linked) {
78
+ throw new SlateDeclarationError(
79
+ `Selection field "${field}" needs its own options list, a linked option list, or both`
80
+ );
81
+ }
82
+ if (list !== undefined && typeof list !== 'function') {
83
+ throw new SlateDeclarationError(`Selection field "${field}" list must be a function`);
84
+ }
85
+ if (linked) {
86
+ if (typeof linked.slate !== 'string' || !LINKED_SLATE_PATTERN.test(linked.slate)) {
87
+ throw new SlateDeclarationError(
88
+ `Selection field "${field}" links to slate "${linked.slate}", which must be written as <scope>/<name>`
89
+ );
90
+ }
91
+ if (typeof linked.optionList !== 'string' || !linked.optionList.trim()) {
92
+ throw new SlateDeclarationError(
93
+ `Selection field "${field}" must name the option list it links to`
94
+ );
95
+ }
96
+ }
97
+
98
+ return {
99
+ ...(list ? { list } : {}),
100
+ ...(linked ? { linked } : {})
101
+ };
102
+ };
103
+
29
104
  export type SlateTriggerGroupPollHandler<ConfigType extends {}, AuthType extends {}> = (
30
105
  context: SlateContext<
31
106
  ConfigType,
@@ -292,6 +367,10 @@ export interface SlateTriggerGroupParameters<
292
367
  webhook?: SlateTriggerGroupWebhookParameters<ConfigType, AuthType>;
293
368
  gateway?: SlateTriggerGroupGatewayParameters<ConfigType, AuthType>;
294
369
  routingMatchers: SlateTriggerGroupRoutingMatchersHandler<ConfigType, AuthType>;
370
+ selectionOptions?: Record<
371
+ string,
372
+ SlateTriggerGroupSelectionOptionsSources<ConfigType, AuthType>
373
+ >;
295
374
  }
296
375
 
297
376
  export class SlateTriggerGroup<
@@ -362,6 +441,10 @@ export class SlateTriggerGroup<
362
441
  get routingMatchers() {
363
442
  return this._params.routingMatchers;
364
443
  }
444
+
445
+ get selectionOptions() {
446
+ return this._params.selectionOptions ?? {};
447
+ }
365
448
  }
366
449
 
367
450
  export class SlateTriggerGroupBuilder<
@@ -375,6 +458,10 @@ export class SlateTriggerGroupBuilder<
375
458
  #gateway: SlateTriggerGroupGatewayParameters<ConfigType, AuthType> | null = null;
376
459
  #routingMatchers: SlateTriggerGroupRoutingMatchersHandler<ConfigType, AuthType> | null =
377
460
  null;
461
+ #selectionOptions: Record<
462
+ string,
463
+ SlateTriggerGroupSelectionOptionsSources<ConfigType, AuthType>
464
+ > = {};
378
465
 
379
466
  constructor(
380
467
  private readonly spec: SlateSpecification<ConfigType, AuthType>,
@@ -497,6 +584,34 @@ export class SlateTriggerGroupBuilder<
497
584
  return this;
498
585
  }
499
586
 
587
+ /**
588
+ * Lets the dashboard offer a list for these selection fields instead of a typed-in value.
589
+ * Each field must be a top-level property of the selection schema, which must be a zod object.
590
+ * For an array property the listed options are values for its items.
591
+ */
592
+ selectionOptions(
593
+ declarations: Record<
594
+ string,
595
+ SlateTriggerGroupSelectionOptionsSources<ConfigType, AuthType>
596
+ >
597
+ ): SlateTriggerGroupBuilder<ConfigType, AuthType, InputType> {
598
+ let schema = this.params.selectionSchema;
599
+ if (!schema) {
600
+ throw new SlateDeclarationError('Selection options require a selection schema');
601
+ }
602
+ if (schema._zod?.def?.type !== 'object') {
603
+ throw new SlateDeclarationError(
604
+ 'Selection options require the selection schema to be a zod object'
605
+ );
606
+ }
607
+
608
+ let shape = (schema as z.ZodObject<any>).shape;
609
+ for (let [field, sources] of Object.entries(declarations)) {
610
+ this.#selectionOptions[field] = checkSelectionOptions(field, sources, shape);
611
+ }
612
+ return this;
613
+ }
614
+
500
615
  build(): SlateTriggerGroup<ConfigType, AuthType, InputType> {
501
616
  if (!this.#source) {
502
617
  throw new SlateDeclarationError(
@@ -513,7 +628,8 @@ export class SlateTriggerGroupBuilder<
513
628
  polling: this.#polling ?? undefined,
514
629
  webhook: this.#webhook ?? undefined,
515
630
  gateway: this.#gateway ?? undefined,
516
- routingMatchers: this.#routingMatchers
631
+ routingMatchers: this.#routingMatchers,
632
+ selectionOptions: this.#selectionOptions
517
633
  });
518
634
  }
519
635
  }
@@ -1,6 +1,12 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { z } from 'zod';
3
- import { SlateTool, SlateTrigger, SlateTriggerGroup, toolCategory } from '../action';
3
+ import {
4
+ optionList,
5
+ SlateTool,
6
+ SlateTrigger,
7
+ SlateTriggerGroup,
8
+ toolCategory
9
+ } from '../action';
4
10
  import { SlateAuth } from '../auth';
5
11
  import { SlateConfig } from '../config';
6
12
  import { Slate } from './slate';
@@ -320,3 +326,66 @@ describe('Slate.create tool categories', () => {
320
326
  ).toThrow('Tool category "crm" lists tool "stray", which is not registered in this slate');
321
327
  });
322
328
  });
329
+
330
+ describe('Slate.create option lists', () => {
331
+ it('keeps option lists in declared order with their params schema', () => {
332
+ let spec = createTestSpec();
333
+ let files = optionList(spec, {
334
+ key: 'files',
335
+ paramsSchema: z.object({ kind: z.string().optional() }),
336
+ list: async ctx => ({
337
+ options: [{ value: ctx.input.params.kind ?? 'any', label: 'Any' }]
338
+ })
339
+ });
340
+ let folders = optionList(spec, {
341
+ key: 'folders',
342
+ list: async () => ({ options: [] })
343
+ });
344
+
345
+ let slate = Slate.create({ spec, tools: [], triggers: [], optionLists: [files, folders] });
346
+
347
+ expect(slate.optionLists.map(list => list.key)).toEqual(['files', 'folders']);
348
+ expect(folders.paramsSchema.parse({})).toEqual({});
349
+ expect(files.paramsSchema.parse({ kind: 'sheet' })).toEqual({ kind: 'sheet' });
350
+ expect(Slate.create({ spec, tools: [], triggers: [] }).optionLists).toEqual([]);
351
+ });
352
+
353
+ it('rejects params with unknown keys for the default and custom params schemas', () => {
354
+ let spec = createTestSpec();
355
+ let list = async () => ({ options: [] });
356
+ let folders = optionList(spec, { key: 'folders', list });
357
+ let files = optionList(spec, {
358
+ key: 'files',
359
+ paramsSchema: z.object({ kind: z.string().optional() }),
360
+ list
361
+ });
362
+ let loose = optionList(spec, {
363
+ key: 'loose',
364
+ paramsSchema: z.object({ kind: z.string() }).passthrough(),
365
+ list
366
+ });
367
+
368
+ expect(folders.paramsSchema.safeParse({ kind: 'sheet' }).success).toBe(false);
369
+ expect(files.paramsSchema.safeParse({ knd: 'sheet' }).success).toBe(false);
370
+ expect(loose.paramsSchema.safeParse({ kind: 'sheet', extra: 1 }).success).toBe(false);
371
+ });
372
+
373
+ it('rejects a params schema that is not a zod object', () => {
374
+ expect(() =>
375
+ optionList(createTestSpec(), {
376
+ key: 'files',
377
+ paramsSchema: z.record(z.string(), z.string()) as any,
378
+ list: async () => ({ options: [] })
379
+ })
380
+ ).toThrow('Option list "files" params schema must be a zod object');
381
+ });
382
+
383
+ it('rejects duplicate option list keys', () => {
384
+ let spec = createTestSpec();
385
+ let list = () => optionList(spec, { key: 'files', list: async () => ({ options: [] }) });
386
+
387
+ expect(() =>
388
+ Slate.create({ spec, tools: [], triggers: [], optionLists: [list(), list()] })
389
+ ).toThrow('Option list "files" is registered more than once');
390
+ });
391
+ });
@@ -1,4 +1,10 @@
1
- import type { SlateTool, SlateToolCategory, SlateTrigger, SlateTriggerGroup } from '../action';
1
+ import type {
2
+ SlateOptionList,
3
+ SlateTool,
4
+ SlateToolCategory,
5
+ SlateTrigger,
6
+ SlateTriggerGroup
7
+ } from '../action';
2
8
  import type { SlateAdapter } from '../adapter';
3
9
  import { SlateDeclarationError } from '../error';
4
10
  import type { SlateSpecification } from './specification';
@@ -13,7 +19,8 @@ export class Slate<ConfigType extends {}, AuthType extends {}> {
13
19
  private readonly _adapters: SlateAdapter<ConfigType, AuthType>[],
14
20
  private readonly _triggerGroups: SlateTriggerGroup<ConfigType, AuthType>[],
15
21
  private readonly _toolCategories: SlateToolCategory[],
16
- private readonly _toolCategoryKeysByActionKey: Map<string, string[]>
22
+ private readonly _toolCategoryKeysByActionKey: Map<string, string[]>,
23
+ private readonly _optionLists: SlateOptionList<ConfigType, AuthType, any>[]
17
24
  ) {}
18
25
 
19
26
  static create<ConfigType extends {}, AuthType extends {}>(params: {
@@ -23,6 +30,7 @@ export class Slate<ConfigType extends {}, AuthType extends {}> {
23
30
  adapters?: SlateAdapter<ConfigType, AuthType>[];
24
31
  triggerGroups?: SlateTriggerGroup<ConfigType, AuthType>[];
25
32
  toolCategories?: SlateToolCategory[];
33
+ optionLists?: SlateOptionList<ConfigType, AuthType, any>[];
26
34
  }) {
27
35
  let adapters = params.adapters ?? [];
28
36
  let actions = [...params.triggers, ...params.tools];
@@ -76,6 +84,17 @@ export class Slate<ConfigType extends {}, AuthType extends {}> {
76
84
  );
77
85
  }
78
86
 
87
+ let optionLists = params.optionLists ?? [];
88
+ let seenOptionListKeys = new Set<string>();
89
+ for (let list of optionLists) {
90
+ if (seenOptionListKeys.has(list.key)) {
91
+ throw new SlateDeclarationError(
92
+ `Option list "${list.key}" is registered more than once`
93
+ );
94
+ }
95
+ seenOptionListKeys.add(list.key);
96
+ }
97
+
79
98
  let toolCategories = params.toolCategories ?? [];
80
99
  let toolCategoryKeysByActionKey = resolveToolCategoryMembership(toolCategories, actions);
81
100
 
@@ -85,7 +104,8 @@ export class Slate<ConfigType extends {}, AuthType extends {}> {
85
104
  adapters,
86
105
  triggerGroups,
87
106
  toolCategories,
88
- toolCategoryKeysByActionKey
107
+ toolCategoryKeysByActionKey,
108
+ optionLists
89
109
  );
90
110
  }
91
111
 
@@ -109,6 +129,10 @@ export class Slate<ConfigType extends {}, AuthType extends {}> {
109
129
  return this._toolCategories ?? [];
110
130
  }
111
131
 
132
+ get optionLists() {
133
+ return this._optionLists ?? [];
134
+ }
135
+
112
136
  // Keys of the tool categories an action belongs to, in declared category order.
113
137
  getToolCategoryKeys(actionKey: string) {
114
138
  return [...(this._toolCategoryKeysByActionKey?.get(actionKey) ?? [])];