@examplary/apps 0.0.0-stage → 1.0.0
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.
- package/README.md +75 -2
- package/package.json +23 -4
- package/src/app.ts +100 -0
- package/src/capabilities/AssessmentPrerequisite.ts +215 -0
- package/src/capabilities/Capability.ts +20 -0
- package/src/capabilities/ExportOption.ts +136 -0
- package/src/capabilities/QuestionType.ts +63 -0
- package/src/capabilities/ResultsDestination.ts +71 -0
- package/src/capabilities/SettingsButton.ts +35 -0
- package/src/capabilities/index.ts +6 -0
- package/src/context.ts +16 -0
- package/src/env.ts +13 -0
- package/src/fields.ts +28 -0
- package/src/index.ts +7 -0
- package/src/types.ts +122 -0
package/README.md
CHANGED
|
@@ -1,3 +1,76 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @examplary/apps
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Types and helpers for writing an Examplary app in TypeScript. An app is an `app.ts` (or
|
|
4
|
+
`app.js`) that default-exports `defineApp()`, with settings, capabilities and hooks added to
|
|
5
|
+
it. Files are referenced by paths relative to the app's directory.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
// app.ts
|
|
9
|
+
import { defineApp, InstallationScope } from "@examplary/apps";
|
|
10
|
+
|
|
11
|
+
import exportCapability from "./capabilities/export";
|
|
12
|
+
|
|
13
|
+
const app = defineApp({
|
|
14
|
+
id: "my-org.my-app",
|
|
15
|
+
name: { en: "My app" },
|
|
16
|
+
description: { en: "Sends tests to my platform" },
|
|
17
|
+
icon: "./icon.svg",
|
|
18
|
+
scope: InstallationScope.Org,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
app.addSetting({
|
|
22
|
+
key: "apiToken",
|
|
23
|
+
label: { en: "API token" },
|
|
24
|
+
type: "password",
|
|
25
|
+
secret: true,
|
|
26
|
+
schema: { type: "string", minLength: 10 },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
app.addCapability(exportCapability);
|
|
30
|
+
|
|
31
|
+
export default app;
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Hooks can read secrets set with `exp set-secret NAME value`, which are never sent to the
|
|
35
|
+
dashboard. Read them inside a hook, not at the top of a module; a missing secret is
|
|
36
|
+
`undefined`:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { env } from "@examplary/apps";
|
|
40
|
+
|
|
41
|
+
const clientSecret = env.GOOGLE_CLIENT_SECRET;
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Hooks run in a sandbox with standard JavaScript, `fetch`, `Headers`, `Response`, `URL`,
|
|
45
|
+
`URLSearchParams`, `TextEncoder`, `crypto.randomUUID()` and `crypto.subtle.digest()`: no Node APIs, and no npm packages that need them.
|
|
46
|
+
`context.org` and `context.user` say who a hook runs for.
|
|
47
|
+
|
|
48
|
+
Capabilities add features to the platform, each with hooks of its own:
|
|
49
|
+
|
|
50
|
+
- `ExportOption` — a "Send to…" entry in the exam export menus.
|
|
51
|
+
- `SettingsButton` — a button on the app's card in the workspace's settings, e.g. "Sync now".
|
|
52
|
+
- `ResultsDestination` — somewhere teachers publish grades to, like a gradebook.
|
|
53
|
+
- `AssessmentPrerequisite` — something a student has to meet before starting a test, like a
|
|
54
|
+
secure browser, with a settings component for the exam's settings and a player component.
|
|
55
|
+
- `QuestionType` — a question type, which workspaces that install the app can use in tests.
|
|
56
|
+
|
|
57
|
+
A `new QuestionType({...})` takes the question type's definition. Its
|
|
58
|
+
`components`, `icon`, `readme` and `previewImage` are paths relative to `app.ts`, e.g.
|
|
59
|
+
`./essay/component-assessment.tsx`. `Essay.addValidator(({ question }) => ({ valid, error }))`
|
|
60
|
+
checks questions of that type, e.g. generated ones, as a hook. `Essay.addSetting({...})` adds a
|
|
61
|
+
setting after those in the definition's `settings`.
|
|
62
|
+
|
|
63
|
+
`app.addSettingsPrepareStep()`, `app.addSettingsValidateStep()` and `app.addUninstallStep()`
|
|
64
|
+
hook into saving the app's settings and removing it from a workspace.
|
|
65
|
+
|
|
66
|
+
A setting's `type` already checks its value (a non-empty string, a URL, a boolean, one of a
|
|
67
|
+
select's options); a `schema` adds a JSON schema for anything else, and `optional: true` lets it
|
|
68
|
+
be left empty. A zod (v4) schema works too, but adds about 450 kB to the server bundle, which
|
|
69
|
+
every hook call loads.
|
|
70
|
+
|
|
71
|
+
A `component` setting renders a React component (a `.tsx` file that default-exports a
|
|
72
|
+
`FrontendSettingsFieldComponent` from `@examplary/ui`), which calls `setValue()` and `submit()`.
|
|
73
|
+
|
|
74
|
+
`npx @examplary/cli publish` evaluates `app.ts`, bundles every component for the browser
|
|
75
|
+
and the app itself for the server, uploads the assets and publishes the result. See
|
|
76
|
+
[docs/apps.md](../../docs/apps.md) for how the platform handles apps.
|
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@examplary/apps",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"packageManager": "yarn@4.8.1",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Define Examplary apps and question types in TypeScript.",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"release": "semantic-release -e semantic-release-monorepo"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"main": "./src/index.ts",
|
|
16
|
+
"types": "./src/index.ts",
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@examplary/schemas": "*"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://examplary.ai/developers",
|
|
21
|
+
"author": {
|
|
22
|
+
"name": "Examplary AI",
|
|
23
|
+
"email": "hi@examplary.ai"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Capability } from "./capabilities/Capability";
|
|
2
|
+
import { resolveComponent, serializeField } from "./fields";
|
|
3
|
+
import {
|
|
4
|
+
AppDefinition,
|
|
5
|
+
FullAppDefinition,
|
|
6
|
+
HookInput,
|
|
7
|
+
SettingsField,
|
|
8
|
+
} from "./types";
|
|
9
|
+
|
|
10
|
+
class App {
|
|
11
|
+
protected definition: FullAppDefinition;
|
|
12
|
+
|
|
13
|
+
constructor(definition: AppDefinition) {
|
|
14
|
+
this.definition = {
|
|
15
|
+
settings: [],
|
|
16
|
+
hooks: [],
|
|
17
|
+
components: [],
|
|
18
|
+
assets: [],
|
|
19
|
+
capabilities: [],
|
|
20
|
+
questionTypes: [],
|
|
21
|
+
...definition,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
toDefinition(): FullAppDefinition {
|
|
26
|
+
return this.definition;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Runs one of the app's hooks; the platform calls this in the sandbox. */
|
|
30
|
+
async runHook({ hook, args, env, context }: HookInput) {
|
|
31
|
+
globalThis.__examplaryEnv = env;
|
|
32
|
+
globalThis.__examplaryContext = context;
|
|
33
|
+
|
|
34
|
+
const definition = this.definition.hooks.find(({ id }) => id === hook);
|
|
35
|
+
if (!definition) {
|
|
36
|
+
throw new Error(`The app has no hook "${hook}"`);
|
|
37
|
+
}
|
|
38
|
+
return definition.fn(...args);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
addCapability(capability: Capability) {
|
|
42
|
+
const def = capability.toDefinition();
|
|
43
|
+
if ("questionType" in def) {
|
|
44
|
+
this.definition.questionTypes.push(def.questionType);
|
|
45
|
+
} else {
|
|
46
|
+
this.definition.capabilities.push(def.capability);
|
|
47
|
+
}
|
|
48
|
+
this.definition.assets.push(...def.assets);
|
|
49
|
+
this.definition.hooks.push(...def.hooks);
|
|
50
|
+
this.definition.components.push(...def.components);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
addSetting(setting: SettingsField) {
|
|
54
|
+
const settingDef = { ...setting };
|
|
55
|
+
if (settingDef.type === "component") {
|
|
56
|
+
if (!settingDef.component) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Needs a component set when type = component for setting "${setting.key}"`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const id = `settings.${setting.key}.component`;
|
|
62
|
+
this.definition.components.push({
|
|
63
|
+
id,
|
|
64
|
+
...resolveComponent(setting.component!),
|
|
65
|
+
});
|
|
66
|
+
settingDef.component = id;
|
|
67
|
+
}
|
|
68
|
+
this.definition.settings.push(serializeField(settingDef));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
addSettingsValidateStep(
|
|
72
|
+
fn: (settings: Record<string, any>) => Promise<boolean> | boolean,
|
|
73
|
+
) {
|
|
74
|
+
this.definition.hooks.push({
|
|
75
|
+
id: "settings.validate",
|
|
76
|
+
fn,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Runs when a workspace removes the app, e.g. to clean up elsewhere; throwing keeps it installed. */
|
|
81
|
+
addUninstallStep(
|
|
82
|
+
fn: (settings: Record<string, any>) => Promise<void> | void,
|
|
83
|
+
) {
|
|
84
|
+
this.definition.hooks.push({ id: "uninstall", fn });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
addSettingsPrepareStep(
|
|
88
|
+
fn: (
|
|
89
|
+
settings: Record<string, any>,
|
|
90
|
+
) => Promise<Record<string, any>> | Record<string, any>,
|
|
91
|
+
) {
|
|
92
|
+
this.definition.hooks.push({
|
|
93
|
+
id: "settings.prepare",
|
|
94
|
+
fn,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const defineApp = <T extends AppDefinition>(definition: T): App =>
|
|
100
|
+
new App(definition);
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { TranslatableType } from "@examplary/schemas";
|
|
2
|
+
|
|
3
|
+
import { Capability, ToCapabilityDefinition } from "./Capability";
|
|
4
|
+
import { resolveComponent, serializeField } from "../fields";
|
|
5
|
+
import { ComponentInput, SettingsField } from "../types";
|
|
6
|
+
|
|
7
|
+
/** The exam a prerequisite applies to, as its hooks see it. */
|
|
8
|
+
export type PrerequisiteExam = {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
published: boolean;
|
|
12
|
+
startDateTime?: string;
|
|
13
|
+
endDateTime?: string;
|
|
14
|
+
/** Whether the browser's spell check may be used */
|
|
15
|
+
spellCheck: boolean;
|
|
16
|
+
/** Where students open the test, and where signed-in students take it */
|
|
17
|
+
publicUrl: string;
|
|
18
|
+
playerUrl: string;
|
|
19
|
+
/** The workspace's own dashboard URL and domain */
|
|
20
|
+
orgUrl: string;
|
|
21
|
+
orgDomain: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** The student's request, with only the headers the prerequisite asked for. */
|
|
25
|
+
export type PrerequisiteRequest = {
|
|
26
|
+
method: string;
|
|
27
|
+
path: string;
|
|
28
|
+
host: string;
|
|
29
|
+
headers: Record<string, string>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type PrerequisiteUser = {
|
|
33
|
+
id: string;
|
|
34
|
+
name?: string;
|
|
35
|
+
firstName?: string;
|
|
36
|
+
lastName?: string;
|
|
37
|
+
email?: string;
|
|
38
|
+
/** Signs the student in and opens the test, valid for an hour */
|
|
39
|
+
signInUrl: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type PrerequisiteFile = {
|
|
43
|
+
contentType: string;
|
|
44
|
+
filename?: string;
|
|
45
|
+
body: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type Check = {
|
|
49
|
+
exam: PrerequisiteExam;
|
|
50
|
+
/** The values of the prerequisite's `examSettings` for this exam */
|
|
51
|
+
examSettings: Record<string, any>;
|
|
52
|
+
/** What `onExamUpdated` stored for this exam */
|
|
53
|
+
data: Record<string, any>;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type Settings = Record<string, any>;
|
|
57
|
+
|
|
58
|
+
type PrerequisiteFunction = {
|
|
59
|
+
/** Who may call it: editors of the exam (e.g. from the settings component), or its students */
|
|
60
|
+
for: "editor" | "student";
|
|
61
|
+
run: (
|
|
62
|
+
settings: Settings,
|
|
63
|
+
call: Check & { user?: PrerequisiteUser; input?: unknown },
|
|
64
|
+
) => unknown;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type AssessmentPrerequisiteDefinition = {
|
|
68
|
+
id: string;
|
|
69
|
+
|
|
70
|
+
/** The label of the switch that turns it on in the exam settings */
|
|
71
|
+
name: TranslatableType;
|
|
72
|
+
|
|
73
|
+
/** Shown in the exam settings once it's turned on */
|
|
74
|
+
description?: TranslatableType;
|
|
75
|
+
|
|
76
|
+
/** The plans the switch is available on, all when left out */
|
|
77
|
+
requiredPlans?: string[];
|
|
78
|
+
|
|
79
|
+
/** Settings per exam, shown below the switch once it's turned on */
|
|
80
|
+
examSettings?: Omit<SettingsField, "scope" | "component">[];
|
|
81
|
+
|
|
82
|
+
/** The request headers the hooks get, e.g. a signature the secure browser adds */
|
|
83
|
+
requestHeaders?: string[];
|
|
84
|
+
|
|
85
|
+
/** Whether the test's completed and timed-out screens offer a button to close the secure browser */
|
|
86
|
+
exitButton?: boolean;
|
|
87
|
+
|
|
88
|
+
/** Shown in the exam settings once it's turned on, e.g. links to the other side */
|
|
89
|
+
settingsComponent?: ComponentInput;
|
|
90
|
+
|
|
91
|
+
/** Shown to students while the prerequisite isn't met, e.g. how to open the secure browser */
|
|
92
|
+
playerComponent?: ComponentInput;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Whether a student may start a session. `input` is what the player
|
|
96
|
+
* component passed with `setValue()`. Met when left out.
|
|
97
|
+
*/
|
|
98
|
+
isMet?: (
|
|
99
|
+
settings: Settings,
|
|
100
|
+
check: Check & { request: PrerequisiteRequest; input?: string },
|
|
101
|
+
) => boolean | Promise<boolean>;
|
|
102
|
+
|
|
103
|
+
/** Runs on every request within a session; throw to refuse it, with a message for the student */
|
|
104
|
+
validateRequest?: (
|
|
105
|
+
settings: Settings,
|
|
106
|
+
check: Check & { request: PrerequisiteRequest },
|
|
107
|
+
) => void | Promise<void>;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Runs when the exam is saved while the prerequisite is turned on, or has
|
|
111
|
+
* data from before. What it returns is stored as the exam's `data`.
|
|
112
|
+
*/
|
|
113
|
+
onExamUpdated?: (
|
|
114
|
+
settings: Settings,
|
|
115
|
+
update: Check & { enabled: boolean },
|
|
116
|
+
) => Settings | void | Promise<Settings | void>;
|
|
117
|
+
|
|
118
|
+
/** Server-side functions the components can call */
|
|
119
|
+
functions?: Record<string, PrerequisiteFunction>;
|
|
120
|
+
|
|
121
|
+
/** Files served publicly by name, e.g. a configuration the secure browser downloads */
|
|
122
|
+
files?: Record<
|
|
123
|
+
string,
|
|
124
|
+
(
|
|
125
|
+
settings: Settings,
|
|
126
|
+
check: Check,
|
|
127
|
+
) => PrerequisiteFile | Promise<PrerequisiteFile>
|
|
128
|
+
>;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** Something students must meet to take a test, like using a secure browser. */
|
|
132
|
+
export class AssessmentPrerequisite extends Capability {
|
|
133
|
+
protected definition: AssessmentPrerequisiteDefinition;
|
|
134
|
+
|
|
135
|
+
constructor(definition: AssessmentPrerequisiteDefinition) {
|
|
136
|
+
super();
|
|
137
|
+
this.definition = definition;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
toDefinition(): ToCapabilityDefinition {
|
|
141
|
+
const {
|
|
142
|
+
id,
|
|
143
|
+
settingsComponent,
|
|
144
|
+
playerComponent,
|
|
145
|
+
isMet,
|
|
146
|
+
validateRequest,
|
|
147
|
+
onExamUpdated,
|
|
148
|
+
functions = {},
|
|
149
|
+
files = {},
|
|
150
|
+
examSettings = [],
|
|
151
|
+
...rest
|
|
152
|
+
} = this.definition;
|
|
153
|
+
const prefix = `capabilities.assessment-prerequisite.${id}`;
|
|
154
|
+
const def: ToCapabilityDefinition = {
|
|
155
|
+
capability: {
|
|
156
|
+
...rest,
|
|
157
|
+
type: "assessment-prerequisite",
|
|
158
|
+
id,
|
|
159
|
+
examSettings: examSettings.map(serializeField),
|
|
160
|
+
},
|
|
161
|
+
hooks: [],
|
|
162
|
+
components: [],
|
|
163
|
+
assets: [],
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const addHook = (name: string, fn: Function) => {
|
|
167
|
+
def.hooks.push({ id: `${prefix}.${name}`, fn });
|
|
168
|
+
return `${prefix}.${name}`;
|
|
169
|
+
};
|
|
170
|
+
const addComponent = (name: string, component: ComponentInput) => {
|
|
171
|
+
def.components.push({
|
|
172
|
+
id: `${prefix}.${name}`,
|
|
173
|
+
...resolveComponent(component),
|
|
174
|
+
});
|
|
175
|
+
return `${prefix}.${name}`;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
if (settingsComponent) {
|
|
179
|
+
def.capability.settingsComponent = addComponent(
|
|
180
|
+
"settings-component",
|
|
181
|
+
settingsComponent,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (playerComponent) {
|
|
185
|
+
def.capability.playerComponent = addComponent(
|
|
186
|
+
"player-component",
|
|
187
|
+
playerComponent,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (isMet) def.capability.isMet = addHook("is-met", isMet);
|
|
191
|
+
if (validateRequest) {
|
|
192
|
+
def.capability.validateRequest = addHook(
|
|
193
|
+
"validate-request",
|
|
194
|
+
validateRequest,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if (onExamUpdated) {
|
|
198
|
+
def.capability.onExamUpdated = addHook("on-exam-updated", onExamUpdated);
|
|
199
|
+
}
|
|
200
|
+
def.capability.functions = Object.fromEntries(
|
|
201
|
+
Object.entries(functions).map(([name, fn]) => [
|
|
202
|
+
name,
|
|
203
|
+
{ for: fn.for, run: addHook(`functions.${name}`, fn.run) },
|
|
204
|
+
]),
|
|
205
|
+
);
|
|
206
|
+
def.capability.files = Object.fromEntries(
|
|
207
|
+
Object.entries(files).map(([name, fn]) => [
|
|
208
|
+
name,
|
|
209
|
+
addHook(`files.${name}`, fn),
|
|
210
|
+
]),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
return def;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CapabilityDefinition,
|
|
3
|
+
HookDefinition,
|
|
4
|
+
ComponentDefinition,
|
|
5
|
+
AssetDefinition,
|
|
6
|
+
QuestionTypeDefinition,
|
|
7
|
+
} from "../types";
|
|
8
|
+
|
|
9
|
+
export type ToCapabilityDefinition = (
|
|
10
|
+
| { capability: CapabilityDefinition }
|
|
11
|
+
| { questionType: QuestionTypeDefinition }
|
|
12
|
+
) & {
|
|
13
|
+
hooks: HookDefinition[];
|
|
14
|
+
components: ComponentDefinition[];
|
|
15
|
+
assets: AssetDefinition[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export abstract class Capability {
|
|
19
|
+
abstract toDefinition(): ToCapabilityDefinition;
|
|
20
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { TranslatableType } from "@examplary/schemas";
|
|
2
|
+
|
|
3
|
+
import { Capability, ToCapabilityDefinition } from "./Capability";
|
|
4
|
+
import { serializeField } from "../fields";
|
|
5
|
+
import { CapabilityDefinition, SettingsField } from "../types";
|
|
6
|
+
|
|
7
|
+
export enum ExportStatus {
|
|
8
|
+
inProgress = "inProgress",
|
|
9
|
+
completed = "completed",
|
|
10
|
+
failed = "failed",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type ExportData<T> = {
|
|
14
|
+
status: ExportStatus;
|
|
15
|
+
resultUrl?: string;
|
|
16
|
+
/** What went wrong, shown to the user, e.g. the items the other side refused. */
|
|
17
|
+
message?: string;
|
|
18
|
+
data: T;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export enum ExportResourceType {
|
|
22
|
+
Exam = "Exam",
|
|
23
|
+
PracticeSpace = "PracticeSpace",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export enum ExportFormat {
|
|
27
|
+
/** A QTI 2.1 package, without Examplary-specific metadata and interactions */
|
|
28
|
+
Qti21 = "qti21",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What `startExport` gets to export: the platform prepares the formats the option asks for. */
|
|
32
|
+
export type ExportResource = {
|
|
33
|
+
id: string;
|
|
34
|
+
name: string;
|
|
35
|
+
/** Where students open the test */
|
|
36
|
+
publicUrl: string;
|
|
37
|
+
/** Points to earn, or null when the test doesn't show points */
|
|
38
|
+
totalPoints: number | null;
|
|
39
|
+
/** Short-lived download URLs of the requested formats */
|
|
40
|
+
files: Partial<Record<ExportFormat, { url: string; filename: string }>>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type ExportOptionSettingsField = Omit<SettingsField, "scope">;
|
|
44
|
+
|
|
45
|
+
type ExportOptionDefinition = {
|
|
46
|
+
id: string;
|
|
47
|
+
name: TranslatableType;
|
|
48
|
+
|
|
49
|
+
/** The icon to be displayed for the export action */
|
|
50
|
+
icon: string;
|
|
51
|
+
|
|
52
|
+
/** The types of resources that this integration can export */
|
|
53
|
+
resourceTypes: ExportResourceType[];
|
|
54
|
+
|
|
55
|
+
/** Files the platform should prepare for `startExport`, e.g. a QTI package */
|
|
56
|
+
formats?: ExportFormat[];
|
|
57
|
+
|
|
58
|
+
/** Interval in milliseconds at which the export status should be checked */
|
|
59
|
+
statusCheckInterval?: number;
|
|
60
|
+
|
|
61
|
+
/** Get options to be displayed to the user in the 'Send to...' UI */
|
|
62
|
+
getExportOptions?: (
|
|
63
|
+
settings: Record<string, any>,
|
|
64
|
+
) => ExportOptionSettingsField[] | Promise<ExportOptionSettingsField[]>;
|
|
65
|
+
|
|
66
|
+
/** Called to initiate an export of the given resource with the specified settings */
|
|
67
|
+
startExport: (
|
|
68
|
+
settings: Record<string, any>,
|
|
69
|
+
resource: ExportResource,
|
|
70
|
+
) => ExportData<any> | Promise<ExportData<any>>;
|
|
71
|
+
|
|
72
|
+
/** Get an updated export status. By default this immediately returns the current export data */
|
|
73
|
+
getExportStatus?: (
|
|
74
|
+
_settings: Record<string, any>,
|
|
75
|
+
exportData: ExportData<any>,
|
|
76
|
+
) => Promise<ExportData<any>>;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export class ExportOption extends Capability {
|
|
80
|
+
protected definition: CapabilityDefinition;
|
|
81
|
+
|
|
82
|
+
constructor(definition: ExportOptionDefinition) {
|
|
83
|
+
super();
|
|
84
|
+
this.definition = {
|
|
85
|
+
type: "export-option",
|
|
86
|
+
statusCheckInterval: 1000,
|
|
87
|
+
...definition,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
toDefinition() {
|
|
92
|
+
const prefix = `capabilities.export-exam-option.${this.definition.id}`;
|
|
93
|
+
const def: ToCapabilityDefinition = {
|
|
94
|
+
capability: { ...this.definition },
|
|
95
|
+
hooks: [],
|
|
96
|
+
components: [],
|
|
97
|
+
assets: [],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const input = this.definition as any as ExportOptionDefinition;
|
|
101
|
+
|
|
102
|
+
if (input.icon) {
|
|
103
|
+
def.assets.push({
|
|
104
|
+
id: `${prefix}.icon`,
|
|
105
|
+
path: input.icon,
|
|
106
|
+
});
|
|
107
|
+
def.capability.icon = `${prefix}.icon`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (input.getExportOptions) {
|
|
111
|
+
const getExportOptions = input.getExportOptions;
|
|
112
|
+
def.hooks.push({
|
|
113
|
+
id: `${prefix}.get-export-options`,
|
|
114
|
+
fn: async (settings: Record<string, any>) =>
|
|
115
|
+
(await getExportOptions(settings)).map(serializeField),
|
|
116
|
+
});
|
|
117
|
+
def.capability.getExportOptions = `${prefix}.get-export-options`;
|
|
118
|
+
}
|
|
119
|
+
if (input.startExport) {
|
|
120
|
+
def.hooks.push({
|
|
121
|
+
id: `${prefix}.start-export`,
|
|
122
|
+
fn: input.startExport,
|
|
123
|
+
});
|
|
124
|
+
def.capability.startExport = `${prefix}.start-export`;
|
|
125
|
+
}
|
|
126
|
+
if (input.getExportStatus) {
|
|
127
|
+
def.hooks.push({
|
|
128
|
+
id: `${prefix}.get-export-status`,
|
|
129
|
+
fn: input.getExportStatus,
|
|
130
|
+
});
|
|
131
|
+
def.capability.getExportStatus = `${prefix}.get-export-status`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return def;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Question, SettingType } from "@examplary/schemas";
|
|
2
|
+
|
|
3
|
+
import { Capability, ToCapabilityDefinition } from "./Capability";
|
|
4
|
+
import { QuestionTypeDefinition } from "../types";
|
|
5
|
+
|
|
6
|
+
export type QuestionValidation = { valid: boolean; error?: string };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A question type the app adds to Examplary. Its file paths are relative to
|
|
10
|
+
* `app.ts`, e.g. `./essay/component-assessment.tsx`.
|
|
11
|
+
*/
|
|
12
|
+
export class QuestionType extends Capability {
|
|
13
|
+
protected definition: QuestionTypeDefinition;
|
|
14
|
+
private functions: Record<string, Function> = {};
|
|
15
|
+
|
|
16
|
+
constructor(definition: QuestionTypeDefinition) {
|
|
17
|
+
super();
|
|
18
|
+
this.definition = definition;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Adds a setting teachers fill in for a question of this type, after those in its definition. */
|
|
22
|
+
addSetting(setting: SettingType) {
|
|
23
|
+
this.definition = {
|
|
24
|
+
...this.definition,
|
|
25
|
+
settings: [...(this.definition.settings ?? []), setting],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Checks a question of this type, e.g. one AI generated, which is tried
|
|
31
|
+
* again with the `error` when it isn't valid. Runs on the server, as a hook.
|
|
32
|
+
*/
|
|
33
|
+
addValidator(
|
|
34
|
+
fn: (input: {
|
|
35
|
+
question: Question;
|
|
36
|
+
}) => QuestionValidation | Promise<QuestionValidation>,
|
|
37
|
+
) {
|
|
38
|
+
this.functions["validate-question"] = fn;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
toDefinition(): ToCapabilityDefinition {
|
|
42
|
+
const hookId = (name: string) =>
|
|
43
|
+
`question-types.${this.definition.id}.${name}`;
|
|
44
|
+
const names = Object.keys(this.functions);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
questionType: names.length
|
|
48
|
+
? {
|
|
49
|
+
...this.definition,
|
|
50
|
+
hooks: Object.fromEntries(
|
|
51
|
+
names.map((name) => [name, hookId(name)]),
|
|
52
|
+
),
|
|
53
|
+
}
|
|
54
|
+
: this.definition,
|
|
55
|
+
hooks: names.map((name) => ({
|
|
56
|
+
id: hookId(name),
|
|
57
|
+
fn: this.functions[name],
|
|
58
|
+
})),
|
|
59
|
+
components: [],
|
|
60
|
+
assets: [],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { TranslatableType } from "@examplary/schemas";
|
|
2
|
+
|
|
3
|
+
import { Capability, ToCapabilityDefinition } from "./Capability";
|
|
4
|
+
|
|
5
|
+
/** An exam's new and changed grades, of students synced from a student information system. */
|
|
6
|
+
export type ResultsToPublish = {
|
|
7
|
+
exam: { id: string; name: string; takenAt: string };
|
|
8
|
+
/** The publishing teacher's LAS key, as the SIS sync stored it */
|
|
9
|
+
teacherLasKey?: string;
|
|
10
|
+
students: {
|
|
11
|
+
/** With the exam ID, identifies the grade, so publishing again updates it */
|
|
12
|
+
studentId: string;
|
|
13
|
+
externalId: string;
|
|
14
|
+
points: number;
|
|
15
|
+
maxPoints: number;
|
|
16
|
+
result: { type: "Grade0.0-10.0" | "PassOrFail" | "OVG"; value: string };
|
|
17
|
+
createdAt: string;
|
|
18
|
+
updatedAt: string;
|
|
19
|
+
}[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type PublishResultsOutcome = {
|
|
23
|
+
accepted: boolean;
|
|
24
|
+
/** Why the results weren't accepted, shown to the teacher */
|
|
25
|
+
message?: string;
|
|
26
|
+
/** What the other side answered, kept in the audit log */
|
|
27
|
+
response?: unknown;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type ResultsDestinationDefinition = {
|
|
31
|
+
id: string;
|
|
32
|
+
|
|
33
|
+
/** Where the results go, as teachers know it, e.g. "Magister" */
|
|
34
|
+
name: TranslatableType;
|
|
35
|
+
|
|
36
|
+
/** Settings that have to be filled in for teachers to be offered this destination */
|
|
37
|
+
requiresSettings?: string[];
|
|
38
|
+
|
|
39
|
+
publishResults: (
|
|
40
|
+
settings: Record<string, any>,
|
|
41
|
+
results: ResultsToPublish,
|
|
42
|
+
) => PublishResultsOutcome | Promise<PublishResultsOutcome>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Somewhere teachers can publish an exam's grades to from the publish results dialog, e.g. a gradebook. */
|
|
46
|
+
export class ResultsDestination extends Capability {
|
|
47
|
+
protected definition: ResultsDestinationDefinition;
|
|
48
|
+
|
|
49
|
+
constructor(definition: ResultsDestinationDefinition) {
|
|
50
|
+
super();
|
|
51
|
+
this.definition = definition;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
toDefinition(): ToCapabilityDefinition {
|
|
55
|
+
const { id, name, requiresSettings = [], publishResults } = this.definition;
|
|
56
|
+
const hook = `capabilities.results-destination.${id}.publish-results`;
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
capability: {
|
|
60
|
+
type: "results-destination",
|
|
61
|
+
id,
|
|
62
|
+
name,
|
|
63
|
+
requiresSettings,
|
|
64
|
+
publishResults: hook,
|
|
65
|
+
},
|
|
66
|
+
hooks: [{ id: hook, fn: publishResults }],
|
|
67
|
+
components: [],
|
|
68
|
+
assets: [],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { TranslatableType } from "@examplary/schemas";
|
|
2
|
+
|
|
3
|
+
import { Capability, ToCapabilityDefinition } from "./Capability";
|
|
4
|
+
|
|
5
|
+
type SettingsButtonDefinition = {
|
|
6
|
+
id: string;
|
|
7
|
+
|
|
8
|
+
/** The label of the button on the app's card */
|
|
9
|
+
label: TranslatableType;
|
|
10
|
+
|
|
11
|
+
/** Runs when an admin clicks the button; the message of an error thrown here is shown to them */
|
|
12
|
+
run: (settings: Record<string, any>) => Promise<void> | void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** A button on the app's card in the workspace's settings, e.g. "Sync now". */
|
|
16
|
+
export class SettingsButton extends Capability {
|
|
17
|
+
protected definition: SettingsButtonDefinition;
|
|
18
|
+
|
|
19
|
+
constructor(definition: SettingsButtonDefinition) {
|
|
20
|
+
super();
|
|
21
|
+
this.definition = definition;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
toDefinition(): ToCapabilityDefinition {
|
|
25
|
+
const { id, label, run } = this.definition;
|
|
26
|
+
const hook = `capabilities.settings-button.${id}.run`;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
capability: { type: "settings-button", id, label, run: hook },
|
|
30
|
+
hooks: [{ id: hook, fn: run }],
|
|
31
|
+
components: [],
|
|
32
|
+
assets: [],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { HookContext } from "./types";
|
|
2
|
+
|
|
3
|
+
declare global {
|
|
4
|
+
/** Who a hook runs for, provided by the platform when it runs a hook. */
|
|
5
|
+
var __examplaryContext: HookContext | undefined;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Who the running hook is for: the workspace, and the user when there is one. */
|
|
9
|
+
export const context: HookContext = {
|
|
10
|
+
get org() {
|
|
11
|
+
return globalThis.__examplaryContext?.org as string;
|
|
12
|
+
},
|
|
13
|
+
get user() {
|
|
14
|
+
return globalThis.__examplaryContext?.user;
|
|
15
|
+
},
|
|
16
|
+
};
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
declare global {
|
|
2
|
+
/** The app's environment, provided by the platform when it runs a hook. */
|
|
3
|
+
var __examplaryEnv: Record<string, string> | undefined;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Secrets set with `exp set-secret`, available in the app's hooks. Values
|
|
8
|
+
* are read when accessed, so read them inside a hook, not at the top of a module.
|
|
9
|
+
*/
|
|
10
|
+
export const env: Record<string, string | undefined> = new Proxy(
|
|
11
|
+
{},
|
|
12
|
+
{ get: (_, name) => globalThis.__examplaryEnv?.[name as string] },
|
|
13
|
+
);
|
package/src/fields.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { ComponentInput, JsonSchema, ZodSchema } from "./types";
|
|
2
|
+
|
|
3
|
+
const isZodSchema = (schema: JsonSchema | ZodSchema): schema is ZodSchema =>
|
|
4
|
+
typeof schema.toJSONSchema === "function";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A settings field with its JSON schema as text, so it can be stored and sent
|
|
8
|
+
* around as is (DynamoDB would turn an empty string in it into null). A zod
|
|
9
|
+
* schema is converted, and tells whether the field is optional.
|
|
10
|
+
*/
|
|
11
|
+
export const serializeField = <T extends Record<string, any>>({
|
|
12
|
+
schema,
|
|
13
|
+
...field
|
|
14
|
+
}: T) => {
|
|
15
|
+
if (!schema) return field;
|
|
16
|
+
if (!isZodSchema(schema)) {
|
|
17
|
+
return { ...field, schema: JSON.stringify(schema) };
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
...field,
|
|
21
|
+
schema: JSON.stringify(schema.toJSONSchema()),
|
|
22
|
+
optional: field.optional ?? schema.safeParse(undefined).success,
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** A component given by its path, as a component definition without an ID. */
|
|
27
|
+
export const resolveComponent = (component: ComponentInput) =>
|
|
28
|
+
typeof component === "string" ? { path: component } : component;
|
package/src/index.ts
ADDED
package/src/types.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
App,
|
|
3
|
+
AppCapabilityType,
|
|
4
|
+
AppSettingsFieldType,
|
|
5
|
+
QuestionType,
|
|
6
|
+
} from "@examplary/schemas";
|
|
7
|
+
|
|
8
|
+
/** What the platform passes to `App.runHook()` when it calls a hook. */
|
|
9
|
+
/** Who a hook runs for, see `context`. */
|
|
10
|
+
export type HookContext = {
|
|
11
|
+
/** The ID of the workspace */
|
|
12
|
+
org: string;
|
|
13
|
+
/** The ID of the user, when a user started it */
|
|
14
|
+
user?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type HookInput = {
|
|
18
|
+
hook: string;
|
|
19
|
+
args: unknown[];
|
|
20
|
+
env: Record<string, string>;
|
|
21
|
+
context: HookContext;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** App definition */
|
|
25
|
+
export type AppDefinition = Omit<
|
|
26
|
+
App,
|
|
27
|
+
| "settings"
|
|
28
|
+
| "capabilities"
|
|
29
|
+
| "hooks"
|
|
30
|
+
| "components"
|
|
31
|
+
| "assets"
|
|
32
|
+
| "questionTypes"
|
|
33
|
+
>;
|
|
34
|
+
|
|
35
|
+
/** Installation scope */
|
|
36
|
+
export enum InstallationScope {
|
|
37
|
+
User = "user",
|
|
38
|
+
Org = "org",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type ComponentInput =
|
|
42
|
+
| string
|
|
43
|
+
| {
|
|
44
|
+
path: string;
|
|
45
|
+
translations?: Record<string, Record<string, string>>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** A JSON schema, e.g. `{ type: "string", pattern: "^woots_" }` */
|
|
49
|
+
export type JsonSchema = Record<string, unknown>;
|
|
50
|
+
|
|
51
|
+
/** A zod (v4) schema, without depending on zod */
|
|
52
|
+
export type ZodSchema = {
|
|
53
|
+
toJSONSchema: () => unknown;
|
|
54
|
+
safeParse: (value: unknown) => { success: boolean };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type SettingsField = Omit<
|
|
58
|
+
AppSettingsFieldType,
|
|
59
|
+
"component" | "schema"
|
|
60
|
+
> & {
|
|
61
|
+
component?: ComponentInput;
|
|
62
|
+
/**
|
|
63
|
+
* What the value has to match, when its `type` alone isn't enough. A JSON
|
|
64
|
+
* schema keeps the app's server bundle small; zod adds about 450 kB to it.
|
|
65
|
+
*/
|
|
66
|
+
schema?: JsonSchema | ZodSchema;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type CapabilityDefinition = AppCapabilityType;
|
|
70
|
+
|
|
71
|
+
export type HookDefinition = {
|
|
72
|
+
id: string;
|
|
73
|
+
fn: Function;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type ComponentDefinition = {
|
|
77
|
+
id: string;
|
|
78
|
+
path: string;
|
|
79
|
+
translations?: Record<string, Record<string, string>>;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type AssetDefinition = {
|
|
83
|
+
id: string;
|
|
84
|
+
path: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** A file path relative to `app.ts`, or one per language code. */
|
|
88
|
+
type TranslatablePath = string | Record<string, string>;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A question type the app provides. Its `components` and `icon`,
|
|
92
|
+
* and its `readme` and `previewImage`, are paths relative to `app.ts`.
|
|
93
|
+
*/
|
|
94
|
+
export type QuestionTypeDefinition = Omit<
|
|
95
|
+
QuestionType,
|
|
96
|
+
| "readme"
|
|
97
|
+
| "readmeUrls"
|
|
98
|
+
| "previewImage"
|
|
99
|
+
| "previewImageUrls"
|
|
100
|
+
| "previewData"
|
|
101
|
+
| "stylesheet"
|
|
102
|
+
> & {
|
|
103
|
+
readme?: TranslatablePath;
|
|
104
|
+
previewImage?: TranslatablePath;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export type FullAppDefinition = Omit<
|
|
108
|
+
App,
|
|
109
|
+
| "settings"
|
|
110
|
+
| "hooks"
|
|
111
|
+
| "components"
|
|
112
|
+
| "assets"
|
|
113
|
+
| "capabilities"
|
|
114
|
+
| "questionTypes"
|
|
115
|
+
> & {
|
|
116
|
+
settings: SettingsField[];
|
|
117
|
+
hooks: HookDefinition[];
|
|
118
|
+
components: ComponentDefinition[];
|
|
119
|
+
assets: AssetDefinition[];
|
|
120
|
+
capabilities: AppCapabilityType[];
|
|
121
|
+
questionTypes: QuestionTypeDefinition[];
|
|
122
|
+
};
|