diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs new file mode 100644 index 000000000..5ae114e5c --- /dev/null +++ b/.dependency-cruiser.cjs @@ -0,0 +1,407 @@ +/** @type {import('dependency-cruiser').IConfiguration} */ +module.exports = { + forbidden: [ + { + name: 'no-circular', + severity: 'warn', + comment: + 'This dependency is part of a circular relationship. You might want to revise ' + + 'your solution (i.e. use dependency inversion, make sure the modules have a single responsibility) ', + from: {}, + to: { + circular: true + } + }, + { + name: 'no-orphans', + comment: + "This is an orphan module - it's likely not used (anymore?). Either use it or " + + "remove it. If it's logical this module is an orphan (i.e. it's a config file), " + + "add an exception for it in your dependency-cruiser configuration. By default " + + "this rule does not scrutinize dot-files (e.g. .eslintrc.js), TypeScript declaration " + + "files (.d.ts), tsconfig.json and some of the babel and webpack configs.", + severity: 'warn', + from: { + orphan: true, + pathNot: [ + '(^|/)[.][^/]+[.](?:js|cjs|mjs|ts|cts|mts|json)$', // dot files + '[.]d[.]ts$', // TypeScript declaration files + '(^|/)tsconfig[.]json$', // TypeScript config + '(^|/)(?:babel|webpack)[.]config[.](?:js|cjs|mjs|ts|cts|mts|json)$' // other configs + ] + }, + to: {}, + }, + { + name: 'no-deprecated-core', + comment: + 'A module depends on a node core module that has been deprecated. Find an alternative - these are ' + + "bound to exist - node doesn't deprecate lightly.", + severity: 'warn', + from: {}, + to: { + dependencyTypes: [ + 'core' + ], + path: [ + '^v8/tools/codemap$', + '^v8/tools/consarray$', + '^v8/tools/csvparser$', + '^v8/tools/logreader$', + '^v8/tools/profile_view$', + '^v8/tools/profile$', + '^v8/tools/SourceMap$', + '^v8/tools/splaytree$', + '^v8/tools/tickprocessor-driver$', + '^v8/tools/tickprocessor$', + '^node-inspect/lib/_inspect$', + '^node-inspect/lib/internal/inspect_client$', + '^node-inspect/lib/internal/inspect_repl$', + '^async_hooks$', + '^punycode$', + '^domain$', + '^constants$', + '^sys$', + '^_linklist$', + '^_stream_wrap$' + ], + } + }, + { + name: 'not-to-deprecated', + comment: + 'This module uses a (version of an) npm module that has been deprecated. Either upgrade to a later ' + + 'version of that module, or find an alternative. Deprecated modules are a security risk.', + severity: 'warn', + from: {}, + to: { + dependencyTypes: [ + 'deprecated' + ] + } + }, + // { + // name: 'no-non-package-json', + // severity: 'error', + // comment: + // "This module depends on an npm package that isn't in the 'dependencies' section of your package.json. " + + // "That's problematic as the package either (1) won't be available on live (2 - worse) will be " + + // "available on live with an non-guaranteed version. Fix it by adding the package to the dependencies " + + // "in your package.json.", + // from: {}, + // to: { + // dependencyTypes: [ + // 'npm-no-pkg', + // 'npm-unknown' + // ] + // } + // }, + // { + // name: 'not-to-unresolvable', + // comment: + // "This module depends on a module that cannot be found ('resolved to disk'). If it's an npm " + + // 'module: add it to your package.json. In all other cases you likely already know what to do.', + // severity: 'error', + // from: {}, + // to: { + // couldNotResolve: true + // } + // }, + { + name: 'no-duplicate-dep-types', + comment: + "Likely this module depends on an external ('npm') package that occurs more than once " + + "in your package.json i.e. bot as a devDependencies and in dependencies. This will cause " + + "maintenance problems later on.", + severity: 'warn', + from: {}, + to: { + moreThanOneDependencyType: true, + // as it's pretty common to have a type import be a type only import + // _and_ (e.g.) a devDependency - don't consider type-only dependency + // types for this rule + dependencyTypesNot: ["type-only"] + } + }, + + /* rules you might want to tweak for your specific situation: */ + + { + name: 'not-to-spec', + comment: + 'This module depends on a spec (test) file. The sole responsibility of a spec file is to test code. ' + + "If there's something in a spec that's of use to other modules, it doesn't have that single " + + 'responsibility anymore. Factor it out into (e.g.) a separate utility/ helper or a mock.', + severity: 'error', + from: {}, + to: { + path: '[.](?:spec|test)[.](?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$' + } + }, + { + name: 'not-to-dev-dep', + severity: 'error', + comment: + "This module depends on an npm package from the 'devDependencies' section of your " + + 'package.json. It looks like something that ships to production, though. To prevent problems ' + + "with npm packages that aren't there on production declare it (only!) in the 'dependencies'" + + 'section of your package.json. If this module is development only - add it to the ' + + 'from.pathNot re of the not-to-dev-dep rule in the dependency-cruiser configuration', + from: { + path: '^(packages|apps)', + pathNot: [ + '^packages/eslint-config', + '^packages/prettier-config', + 'apps/nextjs/tailwind.config.cjs', + 'jest[.]config[.](js|mjs|cjs|ts)$', + '[.](?:spec|test)[.](?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$', + '(^|/)__tests__/', + '(^|/)test/', + '(^|/)tests/', + '(^|/)tests-e2e/' + ] + }, + to: { + dependencyTypes: [ + 'npm-dev', + ], + // type only dependencies are not a problem as they don't end up in the + // production code or are ignored by the runtime. + dependencyTypesNot: [ + 'type-only' + ], + pathNot: [ + 'node_modules/@types/' + ] + } + }, + { + name: 'optional-deps-used', + severity: 'info', + comment: + "This module depends on an npm package that is declared as an optional dependency " + + "in your package.json. As this makes sense in limited situations only, it's flagged here. " + + "If you're using an optional dependency here by design - add an exception to your" + + "dependency-cruiser configuration.", + from: {}, + to: { + dependencyTypes: [ + 'npm-optional' + ] + } + }, + { + name: 'peer-deps-used', + comment: + "This module depends on an npm package that is declared as a peer dependency " + + "in your package.json. This makes sense if your package is e.g. a plugin, but in " + + "other cases - maybe not so much. If the use of a peer dependency is intentional " + + "add an exception to your dependency-cruiser configuration.", + severity: 'warn', + from: {}, + to: { + dependencyTypes: [ + 'npm-peer' + ] + } + } + ], + options: { + + /* Which modules not to follow further when encountered */ + doNotFollow: { + /* path: an array of regular expressions in strings to match against */ + path: ['(^|/)node_modules/','node_modules', '(^|/)dist/', '(^|/)build/', '(^|/)out/'] + }, + + /* Which modules to exclude */ + exclude : { + /* path: an array of regular expressions in strings to match against */ + path: [ + '^apps/nextjs/playwright-report', + '(^|/)\\.turbo/', + '(^|/)\\.github/', + '(^|/)coverage/', + '(^|/)\\.next/', + '(^|/)\\.storybook/', + '\\.test\\.(js|ts|jsx|tsx)$', + '\\.spec\\.(js|ts|jsx|tsx)$', + '\\.stories\\.(js|ts|jsx|tsx)$' + ] + }, + + /* Which modules to exclusively include (array of regular expressions in strings) + dependency-cruiser will skip everything not matching this pattern + */ + // includeOnly : [''], + + /* List of module systems to cruise. + When left out dependency-cruiser will fall back to the list of _all_ + module systems it knows of. It's the default because it's the safe option + It might come at a performance penalty, though. + moduleSystems: ['amd', 'cjs', 'es6', 'tsd'] + + As in practice only commonjs ('cjs') and ecmascript modules ('es6') + are widely used, you can limit the moduleSystems to those. + */ + + // moduleSystems: ['cjs', 'es6'], + + /* + false: don't look at JSDoc imports (the default) + true: dependency-cruiser will detect dependencies in JSDoc-style + import statements. Implies "parser": "tsc", so the dependency-cruiser + will use the typescript parser for JavaScript files. + + For this to work the typescript compiler will need to be installed in the + same spot as you're running dependency-cruiser from. + */ + // detectJSDocImports: true, + + /* prefix for links in html and svg output (e.g. 'https://github.com/you/yourrepo/blob/main/' + to open it on your online repo or `vscode://file/${process.cwd()}/` to + open it in visual studio code), + */ + // prefix: `vscode://file/${process.cwd()}/`, + + /* false (the default): ignore dependencies that only exist before typescript-to-javascript compilation + true: also detect dependencies that only exist before typescript-to-javascript compilation + "specify": for each dependency identify whether it only exists before compilation or also after + */ + tsPreCompilationDeps: true, + + /* list of extensions to scan that aren't javascript or compile-to-javascript. + Empty by default. Only put extensions in here that you want to take into + account that are _not_ parsable. + */ + // extraExtensionsToScan: [".json", ".jpg", ".png", ".svg", ".webp"], + + /* if true combines the package.jsons found from the module up to the base + folder the cruise is initiated from. Useful for how (some) mono-repos + manage dependencies & dependency definitions. + */ + // combinedDependencies: false, + + /* if true leave symlinks untouched, otherwise use the realpath */ + // preserveSymlinks: false, + + /* TypeScript project file ('tsconfig.json') to use for + (1) compilation and + (2) resolution (e.g. with the paths property) + + The (optional) fileName attribute specifies which file to take (relative to + dependency-cruiser's current working directory). When not provided + defaults to './tsconfig.json'. + */ + tsConfig: { + fileName: 'tsconfig.json' + }, + + /* Webpack configuration to use to get resolve options from. + + The (optional) fileName attribute specifies which file to take (relative + to dependency-cruiser's current working directory. When not provided defaults + to './webpack.conf.js'. + + The (optional) `env` and `arguments` attributes contain the parameters + to be passed if your webpack config is a function and takes them (see + webpack documentation for details) + */ + // webpackConfig: { + // fileName: 'webpack.config.js', + // env: {}, + // arguments: {} + // }, + + /* Babel config ('.babelrc', '.babelrc.json', '.babelrc.json5', ...) to use + for compilation + */ + // babelConfig: { + // fileName: '.babelrc', + // }, + + /* List of strings you have in use in addition to cjs/ es6 requires + & imports to declare module dependencies. Use this e.g. if you've + re-declared require, use a require-wrapper or use window.require as + a hack. + */ + // exoticRequireStrings: [], + + /* options to pass on to enhanced-resolve, the package dependency-cruiser + uses to resolve module references to disk. The values below should be + suitable for most situations + + If you use webpack: you can also set these in webpack.conf.js. The set + there will override the ones specified here. + */ + enhancedResolveOptions: { + /* What to consider as an 'exports' field in package.jsons */ + exportsFields: ["exports"], + /* List of conditions to check for in the exports field. + Only works when the 'exportsFields' array is non-empty. + */ + conditionNames: ["import", "require", "node", "default", "types"], + /* + The extensions, by default are the same as the ones dependency-cruiser + can access (run `npx depcruise --info` to see which ones that are in + _your_ environment). If that list is larger than you need you can pass + the extensions you actually use (e.g. [".js", ".jsx"]). This can speed + up module resolution, which is the most expensive step. + */ + // extensions: [".js", ".jsx", ".ts", ".tsx", ".d.ts"], + /* What to consider a 'main' field in package.json */ + mainFields: ["module", "main", "types", "typings"], + /* + A list of alias fields in package.jsons + See [this specification](https://github.com/defunctzombie/package-browser-field-spec) and + the webpack [resolve.alias](https://webpack.js.org/configuration/resolve/#resolvealiasfields) + documentation + + Defaults to an empty array (= don't use alias fields). + */ + // aliasFields: ["browser"], + }, + reporterOptions: { + dot: { + /* pattern of modules that can be consolidated in the detailed + graphical dependency graph. The default pattern in this configuration + collapses everything in node_modules to one folder deep so you see + the external modules, but their innards. + */ + collapsePattern: 'node_modules/(?:@[^/]+/[^/]+|[^/]+)', + + /* Options to tweak the appearance of your graph.See + https://github.com/sverweij/dependency-cruiser/blob/main/doc/options-reference.md#reporteroptions + for details and some examples. If you don't specify a theme + dependency-cruiser falls back to a built-in one. + */ + // theme: { + // graph: { + // /* splines: "ortho" gives straight lines, but is slow on big graphs + // splines: "true" gives bezier curves (fast, not as nice as ortho) + // */ + // splines: "true" + // }, + // } + }, + archi: { + /* pattern of modules that can be consolidated in the high level + graphical dependency graph. If you use the high level graphical + dependency graph reporter (`archi`) you probably want to tweak + this collapsePattern to your situation. + */ + collapsePattern: '^(?:packages|src|lib(s?)|app(s?)|bin|test(s?)|spec(s?))/[^/]+|node_modules/(?:@[^/]+/[^/]+|[^/]+)', + + /* Options to tweak the appearance of your graph. If you don't specify a + theme for 'archi' dependency-cruiser will use the one specified in the + dot section above and otherwise use the default one. + */ + // theme: { }, + }, + "text": { + "highlightFocused": true + }, + } + } +}; +// generated: dependency-cruiser@16.7.0 on 2024-12-10T09:31:39.848Z diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index 8e608153e..000000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,13 +0,0 @@ -/** @type {import("eslint").Linter.Config} */ -module.exports = { - root: true, - extends: ["eslint-config-custom"], - parserOptions: { - tsconfigRootDir: __dirname, - project: [ - "./tsconfig.json", - "./apps/*/tsconfig.json", - "./packages/*/tsconfig.json", - ], - }, -}; diff --git a/.github/actions/ref_from_sha/action.yml b/.github/actions/ref_from_sha/action.yml index a589b58af..06e9edcc4 100644 --- a/.github/actions/ref_from_sha/action.yml +++ b/.github/actions/ref_from_sha/action.yml @@ -13,4 +13,4 @@ outputs: description: "The number of the PR if one is found" runs: using: "node20" - main: "index.js" + main: "index.cjs" diff --git a/.github/actions/ref_from_sha/branch_from_sha.js b/.github/actions/ref_from_sha/branch_from_sha.cjs similarity index 100% rename from .github/actions/ref_from_sha/branch_from_sha.js rename to .github/actions/ref_from_sha/branch_from_sha.cjs diff --git a/.github/actions/ref_from_sha/index.js b/.github/actions/ref_from_sha/index.cjs similarity index 95% rename from .github/actions/ref_from_sha/index.js rename to .github/actions/ref_from_sha/index.cjs index 9356f47e3..393ca3ef3 100644 --- a/.github/actions/ref_from_sha/index.js +++ b/.github/actions/ref_from_sha/index.cjs @@ -8,8 +8,8 @@ const core = require("@actions/core"); const github = require("@actions/github"); -const prFromSha = require("./pr_from_sha"); -const branchFromSha = require("./branch_from_sha"); +const prFromSha = require("./pr_from_sha.cjs"); +const branchFromSha = require("./branch_from_sha.cjs"); async function run() { try { diff --git a/.github/actions/ref_from_sha/pr_from_sha.js b/.github/actions/ref_from_sha/pr_from_sha.cjs similarity index 100% rename from .github/actions/ref_from_sha/pr_from_sha.js rename to .github/actions/ref_from_sha/pr_from_sha.cjs diff --git a/.github/actions/ref_from_sha/test/e2e.js b/.github/actions/ref_from_sha/test/e2e.cjs similarity index 90% rename from .github/actions/ref_from_sha/test/e2e.js rename to .github/actions/ref_from_sha/test/e2e.cjs index 66d036752..475e3933a 100644 --- a/.github/actions/ref_from_sha/test/e2e.js +++ b/.github/actions/ref_from_sha/test/e2e.cjs @@ -1,7 +1,7 @@ const github = require("@actions/github"); -const prFromSha = require("../pr_from_sha"); -const branchFromSha = require("../branch_from_sha"); +const prFromSha = require("../pr_from_sha.cjs"); +const branchFromSha = require("../branch_from_sha.cjs"); const githubToken = process.env.GITHUB_TOKEN; diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e60d78164..99ed5a7a7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -32,6 +32,9 @@ jobs: - name: Install deps (with cache) run: pnpm install + - name: Prisma generate (no engine) + run: pnpm turbo db-generate:no-engine --cache-dir=".turbo" + - name: Build, lint and type-check run: pnpm turbo lint type-check --cache-dir=".turbo" diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 5195a8f95..36f0fb30b 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -38,8 +38,17 @@ jobs: doppler-token: ${{ secrets.DOPPLER_TOKEN }} inject-env-vars: true + - name: Get App Version + id: app-version + run: | + APP_VERSION=$(grep -m 1 -oP '(?<=## \[)\d+\.\d+\.\d+' CHANGE_LOG.md) + echo "version=$APP_VERSION" >> $GITHUB_OUTPUT + - name: SonarCloud scan uses: sonarsource/sonarcloud-github-action@master env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ steps.doppler.outputs.SONAR_TOKEN }} + with: + args: > + -Dsonar.projectVersion=${{ steps.app-version.outputs.version }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cad1aa60e..149fec4e0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,4 +43,15 @@ jobs: inject-env-vars: true - name: Run tests - run: pnpm turbo test --cache-dir=".turbo" -- --maxWorkers=33% + run: pnpm turbo test --cache-dir=".turbo" -- --maxWorkers=33% --coverage + + # Run only on production branch + - name: Report coverage to SonarCloud + if: ${{ github.event.pull_request.merged == true && github.base_ref == 'production' }} + uses: sonarsource/sonarcloud-github-action@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + with: + args: > + -Dsonar.javascript.lcov.reportPaths=packages/**/coverage/lcov.info,apps/nextjs/coverage/lcov.info diff --git a/.gitignore b/.gitignore index 8b123713e..f71235e32 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,9 @@ yarn-error.log* # Allow each developer to have personal settings, but ignore these files .vscode/launch.json -.vscode/*.settings.json \ No newline at end of file +.vscode/*.settings.json + +.sonar +.sonar/**/* + +dependency-graph.svg \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json index e9af3c6cd..80e519a17 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -5,6 +5,7 @@ "bradlc.vscode-tailwindcss", "Prisma.prisma", "orta.vscode-jest", - "ms-playwright.playwright" + "ms-playwright.playwright", + "sonarsource.sonarlint-vscode" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 97cf21f98..b333aa0d7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,7 +12,17 @@ ], "typescript.tsdk": "node_modules/typescript/lib", "typescript.enablePromptUseWorkspaceTsdk": true, + "cSpell.ignorePaths": [ + "pnpm-lock.json", + "node_modules", + "vscode-extension", + ".git/{info,lfs,logs,refs,objects}/**", + ".git/{index,*refs,*HEAD}", + ".vscode", + ".vscode-insiders" + ], "cSpell.words": [ + "agentic", "Aila", "APIV", "authed", @@ -33,21 +43,27 @@ "COLOR", "compat", "contrib", + "corejs", "cuid", "dashify", "datadoghq", + "datamodel", "dialog", "Dialogs", "distractor", "distractors", + "dmmf", "Docgen", "dockerized", "dopplerhq", "dotenv", + "Dsonar", "EASS", + "Edtech", "EHRC", "estree", "estruyf", + "evenodd", "eyfs", "firstname", "fkey", @@ -72,6 +88,7 @@ "initialisation", "initialise", "inngest", + "ipcountry", "Jayne", "jsonparse", "jsonrepair", @@ -90,8 +107,10 @@ "moderations", "multilogical", "NDJSON", + "Neue", "nextjs", "nocheck", + "Noto", "Nullability", "oakai", "oaknational", @@ -123,6 +142,7 @@ "psql", "pusherapp", "ratelimit", + "rect", "refs", "Regen", "remeda", @@ -131,11 +151,18 @@ "RSHE", "rushstack", "Sedar", + "Segoe", "slidedeck", + "sonarcloud", + "sonarqube", + "sonarsource", + "sslcert", "sslmode", "SUBJ", "superjson", "svgs", + "svix", + "systemjs", "tailwindcss", "tanstack", "testid", @@ -144,6 +171,7 @@ "thenational", "tiktoken", "timeframe", + "transferrables", "transpiles", "trivago", "trpc", diff --git a/CHANGE_LOG.md b/CHANGE_LOG.md index 4da1950e8..e7fb418c6 100644 --- a/CHANGE_LOG.md +++ b/CHANGE_LOG.md @@ -1,3 +1,82 @@ +# [1.19.0](https://github.com/oaknational/oak-ai-lesson-assistant/compare/v1.18.2...v1.19.0) (2024-12-18) + + +### Bug Fixes + +* sonar duplication ([#397](https://github.com/oaknational/oak-ai-lesson-assistant/issues/397)) ([dea3106](https://github.com/oaknational/oak-ai-lesson-assistant/commit/dea3106d249119169e515d7ccebc827f324bdbf3)) + + +### Features + +* soft delete ([#383](https://github.com/oaknational/oak-ai-lesson-assistant/issues/383)) ([9dee4ad](https://github.com/oaknational/oak-ai-lesson-assistant/commit/9dee4adb840a0a5d96e43f02349ad04597862539)) + +## [1.18.2](https://github.com/oaknational/oak-ai-lesson-assistant/compare/v1.18.1...v1.18.2) (2024-12-17) + +## [1.18.1](https://github.com/oaknational/oak-ai-lesson-assistant/compare/v1.18.0...v1.18.1) (2024-12-16) + + +### Bug Fixes + +* improve chances of showing 5 relevant plans ([#444](https://github.com/oaknational/oak-ai-lesson-assistant/issues/444)) ([4955a1e](https://github.com/oaknational/oak-ai-lesson-assistant/commit/4955a1e35fad9345de01ce9b831747059a95c579)) +* posthog consistent event properties ([#440](https://github.com/oaknational/oak-ai-lesson-assistant/issues/440)) ([7c24485](https://github.com/oaknational/oak-ai-lesson-assistant/commit/7c24485f6604cc9a13a0a24058dd5f5a1b0f972e)) +* remove key from chat provider ([#431](https://github.com/oaknational/oak-ai-lesson-assistant/issues/431)) ([35141b3](https://github.com/oaknational/oak-ai-lesson-assistant/commit/35141b3305ac7830feeb7269e8ee1f2611368c9a)) +* temporarily disable no unsafe rules ([#429](https://github.com/oaknational/oak-ai-lesson-assistant/issues/429)) ([a8ada5d](https://github.com/oaknational/oak-ai-lesson-assistant/commit/a8ada5d008188ea3064476a431b041165fad6e81)) + +# [1.18.0](https://github.com/oaknational/oak-ai-lesson-assistant/compare/v1.17.1...v1.18.0) (2024-12-11) + + +### Bug Fixes + +* update eslint config and lint errors ([#422](https://github.com/oaknational/oak-ai-lesson-assistant/issues/422)) ([34774b5](https://github.com/oaknational/oak-ai-lesson-assistant/commit/34774b5b404c154d8c8aa10ae5b687507dc09c85)) +* moderation persist scores [AI-696] ([#418](https://github.com/oaknational/oak-ai-lesson-assistant/issues/418)) ([7539661](https://github.com/oaknational/oak-ai-lesson-assistant/commit/7539661e22fa0e334138e0f796a700662e4e37e3)) +* more flexible and efficient solution for images in docs ([#411](https://github.com/oaknational/oak-ai-lesson-assistant/issues/411)) ([abbff85](https://github.com/oaknational/oak-ai-lesson-assistant/commit/abbff8545476f6c045331a63290fc8f57cf3f54e)) +* upgrade typescript + prettier + eslint with a single shared linting config ([#424](https://github.com/oaknational/oak-ai-lesson-assistant/issues/424)) ([1f4aa9d](https://github.com/oaknational/oak-ai-lesson-assistant/commit/1f4aa9dde84402df089ce4d6947472d389f576a0)) + + +### Features + +* report google drive storage quota ([#425](https://github.com/oaknational/oak-ai-lesson-assistant/issues/425)) ([a0cb485](https://github.com/oaknational/oak-ai-lesson-assistant/commit/a0cb4859ddb975d831a5b64d8d789ee8597aeda8)) +* test coverage ([#406](https://github.com/oaknational/oak-ai-lesson-assistant/issues/406)) ([00767a0](https://github.com/oaknational/oak-ai-lesson-assistant/commit/00767a0148456f57f8fa21e0cb0149cf849f3574)) + +## [1.17.1](https://github.com/oaknational/oak-ai-lesson-assistant/compare/v1.17.0...v1.17.1) (2024-12-03) + + +### Bug Fixes + +* add linting command to db package ([#392](https://github.com/oaknational/oak-ai-lesson-assistant/issues/392)) ([d2177d5](https://github.com/oaknational/oak-ai-lesson-assistant/commit/d2177d5c061e973affd1ea52b0ef025c8c37cb29)) +* address sonar major issues ([#393](https://github.com/oaknational/oak-ai-lesson-assistant/issues/393)) ([202a21f](https://github.com/oaknational/oak-ai-lesson-assistant/commit/202a21fbac0d457514c9944735b174c79dced08c)) +* do not define components inline ([#413](https://github.com/oaknational/oak-ai-lesson-assistant/issues/413)) ([abda175](https://github.com/oaknational/oak-ai-lesson-assistant/commit/abda1753afecd9385b19b695767568abdd4383c1)) +* do not use array index for key / use void for onSubmit ([#409](https://github.com/oaknational/oak-ai-lesson-assistant/issues/409)) ([44b5961](https://github.com/oaknational/oak-ai-lesson-assistant/commit/44b59617f3af8cad83110efdc2cb4df23d06e073)) +* help page cloudflare email ([#399](https://github.com/oaknational/oak-ai-lesson-assistant/issues/399)) ([f6262f2](https://github.com/oaknational/oak-ai-lesson-assistant/commit/f6262f26d470a30ea721343bbab2cbfded77b91d)) +* high and medium severity bugs on sonar cloud - AI-637 ([#379](https://github.com/oaknational/oak-ai-lesson-assistant/issues/379)) ([fb0258e](https://github.com/oaknational/oak-ai-lesson-assistant/commit/fb0258ec2f3c0d5fb79d884d3918827460cba404)) +* icons in dialogs ([#398](https://github.com/oaknational/oak-ai-lesson-assistant/issues/398)) ([9700214](https://github.com/oaknational/oak-ai-lesson-assistant/commit/970021462a94b800dba270130f5ba1b1548e8745)) +* intentionality of async / promise code for question generation ([#402](https://github.com/oaknational/oak-ai-lesson-assistant/issues/402)) ([65d1c5f](https://github.com/oaknational/oak-ai-lesson-assistant/commit/65d1c5f89c1b238e24315c02bde2e9eb253b4da3)) +* memoize the sidebar context provider's value ([#408](https://github.com/oaknational/oak-ai-lesson-assistant/issues/408)) ([60ee010](https://github.com/oaknational/oak-ai-lesson-assistant/commit/60ee0102ea1ee733d6527c5460fd404cd7773292)) +* minor sonar issues ([#390](https://github.com/oaknational/oak-ai-lesson-assistant/issues/390)) ([015cd25](https://github.com/oaknational/oak-ai-lesson-assistant/commit/015cd25984c3e5d1a545afef39fd111aa5245d58)) +* prefer nullish coalescing ([#391](https://github.com/oaknational/oak-ai-lesson-assistant/issues/391)) ([b40def9](https://github.com/oaknational/oak-ai-lesson-assistant/commit/b40def9cfd3d69a0089db861a2f6ed47321a3753)) +* readonly props for icons.tsx ([#389](https://github.com/oaknational/oak-ai-lesson-assistant/issues/389)) ([7b4d5bc](https://github.com/oaknational/oak-ai-lesson-assistant/commit/7b4d5bcc508b3179ea5313ec783aba90d1a7f3ae)) +* revert tabindex change ([#404](https://github.com/oaknational/oak-ai-lesson-assistant/issues/404)) ([ac72713](https://github.com/oaknational/oak-ai-lesson-assistant/commit/ac72713dc54595f6bfacfd99e63899616f18b8ec)) +* sonar maintain issues [#4](https://github.com/oaknational/oak-ai-lesson-assistant/issues/4) ([#405](https://github.com/oaknational/oak-ai-lesson-assistant/issues/405)) ([eca0019](https://github.com/oaknational/oak-ai-lesson-assistant/commit/eca001996a684f8d01465196c1c600d00e43a964)) +* sonar maintain linting [#1](https://github.com/oaknational/oak-ai-lesson-assistant/issues/1) ([#394](https://github.com/oaknational/oak-ai-lesson-assistant/issues/394)) ([f4d95fc](https://github.com/oaknational/oak-ai-lesson-assistant/commit/f4d95fcbf39c3c43c3811f8c2022a37af363826a)) +* sonar maintain linting [#2](https://github.com/oaknational/oak-ai-lesson-assistant/issues/2) ([#395](https://github.com/oaknational/oak-ai-lesson-assistant/issues/395)) ([1ed9d60](https://github.com/oaknational/oak-ai-lesson-assistant/commit/1ed9d6028713b096a68a0558d67608dc9efb812f)) +* sonar maintain linting [#3](https://github.com/oaknational/oak-ai-lesson-assistant/issues/3) ([#403](https://github.com/oaknational/oak-ai-lesson-assistant/issues/403)) ([daa7efe](https://github.com/oaknational/oak-ai-lesson-assistant/commit/daa7efe6a2d5d2501f5108cd3c1ccaec86126655)) +* sonar minors [#5](https://github.com/oaknational/oak-ai-lesson-assistant/issues/5) ([#414](https://github.com/oaknational/oak-ai-lesson-assistant/issues/414)) ([5f749f4](https://github.com/oaknational/oak-ai-lesson-assistant/commit/5f749f42f9f5d3d78c736438e313f4f5eff5406b)) + +# [1.17.0](https://github.com/oaknational/oak-ai-lesson-assistant/compare/v1.16.2...v1.17.0) (2024-11-28) + + +### Bug Fixes + +* assert readonly props ([#381](https://github.com/oaknational/oak-ai-lesson-assistant/issues/381)) ([cd88576](https://github.com/oaknational/oak-ai-lesson-assistant/commit/cd88576c5a337ad30f48783d74ea45d746a60956)) +* help page cloudflare email ([#399](https://github.com/oaknational/oak-ai-lesson-assistant/issues/399)) ([391b67c](https://github.com/oaknational/oak-ai-lesson-assistant/commit/391b67c27a4048d4e422be22226d8d5aa1ac71bd)) +* minor linting ([#384](https://github.com/oaknational/oak-ai-lesson-assistant/issues/384)) ([ec4ce6e](https://github.com/oaknational/oak-ai-lesson-assistant/commit/ec4ce6e5dcc7dbd4e242be65fd3b5f9708b94a40)) +* svg clip-rule should be clipRule in JSX ([#382](https://github.com/oaknational/oak-ai-lesson-assistant/issues/382)) ([610d8c8](https://github.com/oaknational/oak-ai-lesson-assistant/commit/610d8c838273de24212a8531a8bc4b6136c05db1)) + + +### Features + +* move delete all button and restyle side menu ([#375](https://github.com/oaknational/oak-ai-lesson-assistant/issues/375)) ([69b2371](https://github.com/oaknational/oak-ai-lesson-assistant/commit/69b2371bf9ee7e7f783aa191ba6932dba0171837)) + ## [1.16.2](https://github.com/oaknational/oak-ai-lesson-assistant/compare/v1.16.1...v1.16.2) (2024-11-25) diff --git a/README.md b/README.md index fb3ee86ba..52b6c5a00 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,16 @@ Oak AI Lesson Assistant is a project focused on experimenting with AI models and - [End-to-end tests](#end-to-end-tests) - [Playwright tags](#playwright-tags) - [Testing in VSCode](#testing-in-vscode) + - [Standards](#standards) + - [Typescript](#typescript) + - [ES Modules](#esmodules) + - [CommonJS](#commonjs) + - [Code quality](#quality) + - [Sonar](#sonar) + - [ESLint](#eslint) + - [Prettier](#prettier) + - [Tsconfig]("#tsconfig) + - [Dependency cruiser](#dependency-cruiser) - [Release process](#release-process) - [PNPM / dependency problems](#pnpm--dependency-problems) - [External contributions](#external-contributions) @@ -176,6 +186,68 @@ Our Playwright tests are organised with tags: Install the Jest and Playwright extensions recommended in the workspace config. Testing icons should appear in the gutter to the left of each test when viewing a test file. Clicking the icon will run the test. The testing tab in the VSCode nav bar provides an overview. +## Standards + +### Typescript + +By default, we develop in Typescript and aim to be up to date with the latest version. New code should default to being written in Typescript unless it is not possible. + +### ES Modules + +All packages are configured to be ES Modules. + +### CommonJS + +Currently NextJS, Tailwind and some other tools has some code which needs to be CommonJS. For these files, you should use the .cjs extension so that the correct linting rules are applied. + +## Code quality + +We use several tools to ensure code quality in the codebase and for checks during development. These checks run on each PR in Github to ensure we maintain good code quality. You can also run many of these checks locally before making a PR. + +### Sonar + +If you are using VS Code or Cursor, consider installing the SonarQube for IDE extension. This will give you feedback while you were as to any code quality or security issues that it has detected. + +If you would like to run a Sonar scan locally, use the following command: + +```shell +pnpm sonar +``` + +You will need to log in to Sonar when prompted the first time. This will generate a full report for you of your local development environment. Usually it is easier to make a PR and have this run for you automatically. + +### ESLint + +We have a single ESLint config for the whole monorepo. You will find it in packages/eslint-config. + +This is using the latest version of ESLint and you should note that the config file format has changed to the "Flat file" config in version 9. + +Each package does not have its own ESLint config by default. Instead we have a single config file, with regex path matchers to turn on/off rules that are specific for each package. This can be overridden and you can see an example of that in the logger package. + +Each package specifies in its package.json file that it should use this shared config and there is a root ESLint config file for the whole mono repo which specifies that it should do the same. + +To check for linting errors, run the following command: + +```shell +pnpm lint +``` + +If you want to check for linting errors in an individual package, cd into that package and run the same command. + +### Prettier + +We also have a single Prettier config, which is located in packages/prettier-config. In general there should be no need to change this on a per-package basis. + +### Tsconfig + +We have an overall tsconfig.json file which specifies the overall Typescript configuration for the project. Then each package extends from it. + +You can check the codebase for any Typescript issues by running the following command: + +```shell +pnpm type-check +``` + ## Release process The current release process is fully documented [in Notion](https://www.notion.so/oaknationalacademy/Branch-Strategy-and-Release-Process-ceeb32937af0426ba495565288e18844?pvs=4), but broadly works as follows: diff --git a/apps/nextjs/.eslintrc.cjs b/apps/nextjs/.eslintrc.cjs deleted file mode 100644 index 36eabc292..000000000 --- a/apps/nextjs/.eslintrc.cjs +++ /dev/null @@ -1,23 +0,0 @@ -/** @type {import("eslint").Linter.Config} */ -module.exports = { - extends: ["../../.eslintrc.cjs", "next", "plugin:storybook/recommended"], - rules: { - "react/prefer-read-only-props": "error", - "no-restricted-imports": [ - "error", - { - paths: [ - { - name: "posthog-js/react", - importNames: ["usePostHog"], - message: - "usePostHog doesn't support multiple PostHog instances, use useAnalytics instead", - }, - ], - }, - ], - }, - parserOptions: { - project: __dirname + "/tsconfig.json", - }, -}; diff --git a/apps/nextjs/.storybook/chromatic.ts b/apps/nextjs/.storybook/chromatic.ts new file mode 100644 index 000000000..bc755b188 --- /dev/null +++ b/apps/nextjs/.storybook/chromatic.ts @@ -0,0 +1,91 @@ +import "@storybook/csf"; + +type ChromaticModes = "mobile" | "mobile-wide" | "desktop" | "desktop-wide"; + +export function chromaticParams(modes: ChromaticModes[]) { + return { + chromatic: { + modes: { + ...(modes.includes("mobile") && { + mobile: { viewport: "mobile" }, + }), + ...(modes.includes("mobile-wide") && { + mobile: { viewport: "mobile-wide" }, + }), + ...(modes.includes("desktop") && { + desktop: { viewport: "desktop" }, + }), + ...(modes.includes("desktop-wide") && { + "desktop-wide": { viewport: "desktopWide" }, + }), + }, + }, + }; +} + +declare module "@storybook/csf" { + interface Parameters { + /** + * Parameters for chromatic + */ + chromatic?: { + /** + * Delay capture for a fixed time (in milliseconds) to allow your story to get into + * the intended state + * + * @see [delaying snapshots chromatic documentation](https://www.chromatic.com/docs/delay) + */ + delay?: number; + /** + * Override this behavior in instances where a single pixel change is not flagged by + * Chromatic but should be + * + * * @see [anti-aliasing chromatic documentation](https://www.chromatic.com/docs/threshold#anti-aliasing) + * + * @default false + */ + diffIncludeAntiAliasing?: boolean; + /** + * The diffThreshold parameter allows you to fine tune the threshold for visual change + * between snapshots before they're flagged by Chromatic. Sometimes you need assurance + * to the sub-pixel and other times you want to skip visual noise generated by + * non-deterministic rendering such as anti-aliasing. + * + * 0 is the most accurate. 1 is the least accurate. + * + * @default 0.063 + */ + diffThreshold?: number; + /** + * You can omit stories entirely from Chromatic testing using the disable story parameter. + * + * @see [ignoring elements chromatic documentation](https://www.chromatic.com/docs/ignoring-elements) + */ + disable?: boolean; + /** + * Modes + * + * @see [modes chromatic documentation](https://www.chromatic.com/docs/modes) + */ + modes?: Record< + string, + { + viewport?: string | number; + theme?: "light" | "dark"; + backgrounds?: { value: string }; + } + >; + /** + * Define one or more viewport sizes to capture. Note, units are considered in pixels + */ + viewports?: number[]; + /** + * To specify that Chromatic should pause the animation at the end instead of reseting + * them to their beginning state. + * + * @see [animations chromatic documentation](https://www.chromatic.com/docs/animations) + */ + pauseAnimationAtEnd?: boolean; + }; + } +} diff --git a/apps/nextjs/.storybook/decorators/AnalyticsDecorator.tsx b/apps/nextjs/.storybook/decorators/AnalyticsDecorator.tsx new file mode 100644 index 000000000..7ea156986 --- /dev/null +++ b/apps/nextjs/.storybook/decorators/AnalyticsDecorator.tsx @@ -0,0 +1,35 @@ +import React from "react"; + +import type { Decorator } from "@storybook/react"; +import { fn } from "@storybook/test"; + +import { + analyticsContext, + type AnalyticsContext, +} from "../../src/components/ContextProviders/AnalyticsProvider"; + +declare module "@storybook/csf" { + interface Parameters { + analyticsContext?: Partial; + } +} + +export const AnalyticsDecorator: Decorator = (Story, { parameters }) => { + return ( + + + + ); +}; diff --git a/apps/nextjs/.storybook/decorators/ChatDecorator.tsx b/apps/nextjs/.storybook/decorators/ChatDecorator.tsx new file mode 100644 index 000000000..38caa8d10 --- /dev/null +++ b/apps/nextjs/.storybook/decorators/ChatDecorator.tsx @@ -0,0 +1,20 @@ +import React from "react"; + +import type { Decorator } from "@storybook/react"; + +import { + ChatContext, + type ChatContextProps, +} from "../../src/components/ContextProviders/ChatProvider"; + +declare module "@storybook/csf" { + interface Parameters { + chatContext?: Partial; + } +} + +export const ChatDecorator: Decorator = (Story, { parameters }) => ( + + + +); diff --git a/apps/nextjs/.storybook/decorators/DemoDecorator.tsx b/apps/nextjs/.storybook/decorators/DemoDecorator.tsx new file mode 100644 index 000000000..111525528 --- /dev/null +++ b/apps/nextjs/.storybook/decorators/DemoDecorator.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +import type { Decorator } from "@storybook/react"; +import invariant from "tiny-invariant"; + +import { + DemoContext, + type DemoContextProps, +} from "@/components/ContextProviders/Demo"; + +declare module "@storybook/csf" { + interface Parameters { + demoContext?: DemoContextProps; + } +} + +export const DemoDecorator: Decorator = (Story, { parameters }) => { + const value = parameters.demoContext; + invariant( + value, + "DemoDecorator requires a DemoContext. Please call ...demoParams() in the parameters", + ); + + return ( + + + + ); +}; + +const demoBase: DemoContextProps["demo"] = { + appSessionsRemaining: 2, + appSessionsPerMonth: 3, + contactHref: "https://share.hsforms.com/1R9ulYSNPQgqElEHde3KdhAbvumd", +}; + +type DemoParams = { + isDemoUser: boolean; + demo?: Partial; + isSharingEnabled?: boolean; +}; +export const demoParams = ( + args: DemoParams, +): { demoContext: DemoContextProps } => { + const isSharingEnabled = args.isSharingEnabled ?? true; + + const context: DemoContextProps = args.isDemoUser + ? { + isDemoUser: true, + demo: { ...demoBase, ...args.demo }, + isSharingEnabled, + } + : { + isDemoUser: false, + demo: undefined, + isSharingEnabled, + }; + + return { + demoContext: context, + }; +}; diff --git a/apps/nextjs/.storybook/decorators/DialogContentDecorator.tsx b/apps/nextjs/.storybook/decorators/DialogContentDecorator.tsx new file mode 100644 index 000000000..052d7598b --- /dev/null +++ b/apps/nextjs/.storybook/decorators/DialogContentDecorator.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +import type { Decorator } from "@storybook/react"; +import { fn } from "@storybook/test"; + +import type { DialogTypes } from "../../src/components/AppComponents/Chat/Chat/types"; +import { DialogContext } from "../../src/components/AppComponents/DialogContext"; + +declare module "@storybook/csf" { + interface Parameters { + dialogWindow?: DialogTypes; + } +} + +export const DialogContentDecorator: Decorator = (Story, { parameters }) => { + return ( + + + + ); +}; diff --git a/apps/nextjs/.storybook/decorators/LessonPlanTrackingDecorator.tsx b/apps/nextjs/.storybook/decorators/LessonPlanTrackingDecorator.tsx new file mode 100644 index 000000000..96b6b46dc --- /dev/null +++ b/apps/nextjs/.storybook/decorators/LessonPlanTrackingDecorator.tsx @@ -0,0 +1,21 @@ +import React from "react"; + +import type { Decorator } from "@storybook/react"; +import { fn } from "@storybook/test"; + +import { LessonPlanTrackingContext } from "../../src/lib/analytics/lessonPlanTrackingContext"; + +export const LessonPlanTrackingDecorator: Decorator = (Story) => ( + + + +); diff --git a/apps/nextjs/.storybook/decorators/RadixThemeDecorator.tsx b/apps/nextjs/.storybook/decorators/RadixThemeDecorator.tsx index 590c5bf62..457a5a501 100644 --- a/apps/nextjs/.storybook/decorators/RadixThemeDecorator.tsx +++ b/apps/nextjs/.storybook/decorators/RadixThemeDecorator.tsx @@ -1,8 +1,9 @@ import React from "react"; import { Theme } from "@radix-ui/themes"; +import { Decorator } from "@storybook/react"; -export const RadixThemeDecorator = (Story: React.ComponentType) => ( +export const RadixThemeDecorator: Decorator = (Story) => ( diff --git a/apps/nextjs/.storybook/decorators/SidebarDecorator.tsx b/apps/nextjs/.storybook/decorators/SidebarDecorator.tsx new file mode 100644 index 000000000..6d3f44644 --- /dev/null +++ b/apps/nextjs/.storybook/decorators/SidebarDecorator.tsx @@ -0,0 +1,25 @@ +import React from "react"; + +import type { Decorator } from "@storybook/react"; +import { fn } from "@storybook/test"; + +import { SidebarContext } from "../../src/lib/hooks/use-sidebar"; + +declare module "@storybook/csf" { + interface Parameters { + // Please fill out as we add configuration + sidebarContext?: {}; + } +} + +export const SidebarDecorator: Decorator = (Story) => ( + + + +); diff --git a/apps/nextjs/.storybook/preview.tsx b/apps/nextjs/.storybook/preview.tsx index 428690b9c..0f552f1be 100644 --- a/apps/nextjs/.storybook/preview.tsx +++ b/apps/nextjs/.storybook/preview.tsx @@ -8,16 +8,27 @@ import "@fontsource/lexend/800.css"; import "@fontsource/lexend/900.css"; import { OakThemeProvider, oakDefaultTheme } from "@oaknational/oak-components"; import type { Preview, Decorator } from "@storybook/react"; -import { initialize as initializeMsw, mswLoader } from "msw-storybook-addon"; +import { + initialize as initializeMsw, + mswLoader, + MswParameters, +} from "msw-storybook-addon"; import { TooltipProvider } from "../src/components/AppComponents/Chat/ui/tooltip"; import { DialogProvider } from "../src/components/AppComponents/DialogContext"; import { AnalyticsProvider } from "../src/mocks/analytics/provider"; import { ClerkDecorator } from "../src/mocks/clerk/ClerkDecorator"; import { TRPCReactProvider } from "../src/utils/trpc"; +import { chromaticParams } from "./chromatic"; import { RadixThemeDecorator } from "./decorators/RadixThemeDecorator"; import "./preview.css"; +declare module "@storybook/csf" { + interface Parameters { + msw?: MswParameters["msw"]; + } +} + initializeMsw(); const preview: Preview = { @@ -28,24 +39,38 @@ const preview: Preview = { date: /Date$/i, }, }, + viewport: { + viewports: { + mobile: { + name: "Mobile", + styles: { width: "375px", height: "800px" }, + }, + mobileWide: { + name: "Mobile Wide", + styles: { width: "430px", height: "930px" }, + }, + desktop: { + name: "Desktop", + styles: { width: "1200px", height: "1000px" }, + }, + desktopWide: { + name: "Desktop Wide", + styles: { width: "1400px", height: "1000px" }, + }, + }, + }, + ...chromaticParams(["desktop"]), }, loaders: [mswLoader], }; -// Providers not currently used -// - CookieConsentProvider -// - DemoProvider -// - LessonPlanTrackingProvider -// - DialogProvider -// - SidebarProvider -// - ChatModerationProvider +// NOTE: See ./decorators for more decorators available to use in stories export const decorators: Decorator[] = [ RadixThemeDecorator, ClerkDecorator, (Story) => ( <> - {/* TODO: Mock tRPC calls with MSW */} diff --git a/apps/nextjs/.storybook/public/.eslintignore b/apps/nextjs/.storybook/public/.eslintignore new file mode 100644 index 000000000..9718b3bbc --- /dev/null +++ b/apps/nextjs/.storybook/public/.eslintignore @@ -0,0 +1 @@ +apps/nextjs/.storybook/public/mockServiceWorker.js \ No newline at end of file diff --git a/apps/nextjs/__mocks__/nextImageMock.js b/apps/nextjs/__mocks__/nextImageMock.js index d3ad25552..934118671 100644 --- a/apps/nextjs/__mocks__/nextImageMock.js +++ b/apps/nextjs/__mocks__/nextImageMock.js @@ -1,7 +1,14 @@ import React from "react"; +import PropTypes from "prop-types"; + const NextImageMock = ({ src, alt, ...props }) => { return {alt}; }; +NextImageMock.propTypes = { + src: PropTypes.string.isRequired, + alt: PropTypes.string.isRequired, +}; + export default NextImageMock; diff --git a/apps/nextjs/jest.config.js b/apps/nextjs/jest.config.mjs similarity index 55% rename from apps/nextjs/jest.config.js rename to apps/nextjs/jest.config.mjs index 223d2471c..7898f8abb 100644 --- a/apps/nextjs/jest.config.js +++ b/apps/nextjs/jest.config.mjs @@ -1,5 +1,9 @@ -const { pathsToModuleNameMapper } = require("ts-jest"); -const { compilerOptions } = require("./tsconfig.test.json"); +import { readFile } from "fs/promises"; +import { pathsToModuleNameMapper } from "ts-jest"; + +const tsconfig = JSON.parse( + await readFile(new URL("./tsconfig.test.json", import.meta.url)), +); /** @type {import('ts-jest').JestConfigWithTsJest} */ const config = { @@ -9,14 +13,15 @@ const config = { { tsconfig: "tsconfig.test.json", useESM: true, + isolatedModules: true, }, ], - "^.+\\.svg$": "/jest.svgTransform.js", + "^.+\\.svg$": "/jest.svgTransform.mjs", "^.+\\.(css|scss|png|jpg|jpeg|gif|webp|avif)$": "jest-transform-stub", }, preset: "ts-jest/presets/default-esm", moduleNameMapper: { - ...pathsToModuleNameMapper(compilerOptions.paths, { + ...pathsToModuleNameMapper(tsconfig.compilerOptions.paths, { prefix: "/src/", }), "^(\\.{1,2}/.*)\\.js$": "$1", @@ -29,7 +34,12 @@ const config = { moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], rootDir: ".", resetMocks: true, - setupFilesAfterEnv: ["/jest.setup.js"], + setupFilesAfterEnv: ["/jest.setup.cjs"], + collectCoverageFrom: ["src/**/*.{ts,tsx,js,jsx}"], + collectCoverage: + process.env.CI === "true" || process.env.COLLECT_TEST_COVERAGE === "true", + coverageReporters: ["lcov", "text"], + coverageDirectory: "coverage", }; -module.exports = config; +export default config; diff --git a/apps/nextjs/jest.setup.js b/apps/nextjs/jest.setup.cjs similarity index 100% rename from apps/nextjs/jest.setup.js rename to apps/nextjs/jest.setup.cjs diff --git a/apps/nextjs/jest.static.d.ts b/apps/nextjs/jest.static.d.ts index 95daa5e7f..ded555fbb 100644 --- a/apps/nextjs/jest.static.d.ts +++ b/apps/nextjs/jest.static.d.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ declare module "*.svg" { const content: any; export default content; diff --git a/apps/nextjs/jest.svgTransform.js b/apps/nextjs/jest.svgTransform.mjs similarity index 89% rename from apps/nextjs/jest.svgTransform.js rename to apps/nextjs/jest.svgTransform.mjs index 16506e1c5..685e8670c 100644 --- a/apps/nextjs/jest.svgTransform.js +++ b/apps/nextjs/jest.svgTransform.mjs @@ -1,4 +1,4 @@ -module.exports = { +export default { process() { console.log("Called jest svg transform"); return { code: "module.exports = {};" }; diff --git a/apps/nextjs/next.config.js b/apps/nextjs/next.config.js index 6f9dad15f..0dd375c38 100644 --- a/apps/nextjs/next.config.js +++ b/apps/nextjs/next.config.js @@ -1,9 +1,10 @@ +// This file should be in Common JS format to be compatible with Next.js const { getAppVersion, getReleaseStage, RELEASE_STAGE_PRODUCTION, RELEASE_STAGE_TESTING, -} = require("./scripts/build_config_helpers.js"); +} = require("./scripts/build_config_helpers.cjs"); const path = require("path"); const { PHASE_PRODUCTION_BUILD, PHASE_TEST } = require("next/constants"); diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 89f06f7e8..a6a7f072f 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -1,25 +1,27 @@ { "name": "@oakai/nextjs", - "version": "0.1.0", + "version": "1.0.0", "private": true, "scripts": { "build": "next build", "build:dev": "pnpm with-env next build", "check": "tsc --noEmit", "clean": "rm -rf .next .turbo node_modules", - "dev": "FORCE_COLOR=1 SENTRY_SUPPRESS_TURBOPACK_WARNING=1 pnpm with-env node scripts/increase-listeners.js next dev --port 2525 --turbo | pino-pretty -C", - "dev:sentry": "FORCE_COLOR=1 pnpm with-env node scripts/increase-listeners.js next dev --port 2525 | pino-pretty -C", + "dev": "FORCE_COLOR=1 SENTRY_SUPPRESS_TURBOPACK_WARNING=1 pnpm with-env next dev --port 2525 --turbo | pino-pretty -C", + "dev:sentry": "FORCE_COLOR=1 pnpm with-env next dev --port 2525 | pino-pretty -C", "dev-trace-deprecation": "NODE_OPTIONS=\"--trace-deprecation\" next dev --port 2525 | pino-pretty -C", - "lint": "next lint", + "lint": "eslint .", "lint-fix": "next lint --fix", + "lint-debug": "eslint --debug .", "start": "next start", "type-check": "tsc --noEmit", - "test": "pnpm with-env jest --colors --config jest.config.js", - "test:seq": "pnpm with-env jest --colors --config jest.config.js --verbose --runInBand --no-cache", + "test": "pnpm with-env jest --colors --config jest.config.mjs", + "test:seq": "pnpm with-env jest --colors --config jest.config.mjs --verbose --runInBand --no-cache", "test-e2e": "pnpm with-env playwright test", "test-e2e-ui": "pnpm with-env playwright test --ui", "test-e2e-ui-serve": "pnpm build && pnpm start --port 4848 --turbo", "test-e2e-ui-built": "PORT=4848 pnpm with-env playwright test --ui", + "test-coverage": "COLLECT_TEST_COVERAGE=true pnpm with-env jest --colors --config jest.config.mjs --coverage", "with-env": "dotenv -e ../../.env --", "aila": "tsx scripts/aila-cli.ts", "storybook": "dotenv -e ../../.env -- storybook dev -p 6006 --no-open", @@ -42,14 +44,15 @@ "@oakai/api": "*", "@oakai/core": "*", "@oakai/db": "*", + "@oakai/eslint-config": "*", "@oakai/exports": "*", "@oakai/logger": "*", "@oakai/prettier-config": "*", "@oaknational/oak-components": "^1.50.0", "@oaknational/oak-consent-client": "^2.1.0", "@portabletext/react": "^3.1.0", - "@prisma/client": "5.16.1", - "@prisma/extension-accelerate": "^1.0.0", + "@prisma/client": "^5.16.1", + "@prisma/extension-accelerate": "^1.2.1", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-tooltip": "^1.0.7", @@ -93,8 +96,8 @@ "openai": "^4.58.1", "p-limit": "^6.1.0", "partial-json-parser": "^1.2.2", - "posthog-js": "^1.139.1", - "posthog-node": "^4.0.0", + "posthog-js": "^1.201.0", + "posthog-node": "^4.3.2", "pptxgenjs": "^3.12.0", "ramda": "^0.30.1", "react": "18.2.0", @@ -140,8 +143,8 @@ "@storybook/test": "^8.4.1", "@tailwindcss/typography": "^0.5.10", "@types/file-saver": "^2.0.6", - "@types/jest": "^29.5.12", - "@types/node": "^18.17.0", + "@types/jest": "^29.5.14", + "@types/node": "^20.9.0", "@types/react": "^18.2.37", "@types/react-dom": "^18.2.15", "@types/styled-components": "^5.1.34", @@ -149,17 +152,14 @@ "avo": "^3.2.11", "concurrently": "^8.2.2", "dotenv-cli": "^6.0.0", - "eslint": "^8.56.0", - "eslint-config-next": "15.0.1", - "eslint-plugin-storybook": "^0.8.0", "graphql": "^16.9.0", "jest": "^29.7.0", "msw": "^2.6.5", "msw-storybook-addon": "^2.0.4", "postcss": "^8.4.32", "tailwindcss": "^3.3.7", - "ts-jest": "^29.1.4", - "typescript": "5.3.3", + "ts-jest": "^29.2.5", + "typescript": "5.7.2", "web-streams-polyfill": "^4.0.0" }, "engines": { @@ -170,5 +170,12 @@ "workerDirectory": [ ".storybook/public" ] + }, + "eslintConfig": { + "extends": "@oakai/eslint-config", + "parserOptions": { + "project": "./tsconfig.json" + }, + "rules": {} } } diff --git a/apps/nextjs/scripts/build_config_helpers.js b/apps/nextjs/scripts/build_config_helpers.cjs similarity index 99% rename from apps/nextjs/scripts/build_config_helpers.js rename to apps/nextjs/scripts/build_config_helpers.cjs index 4016a0c86..ca4a2b30f 100644 --- a/apps/nextjs/scripts/build_config_helpers.js +++ b/apps/nextjs/scripts/build_config_helpers.cjs @@ -7,7 +7,7 @@ const { existsSync, readFileSync } = require("fs"); * @returns {(string|null)} The SHA if found, or "no_git_state" if mid-merge, or `null` if it cannot be determined. */ function getLocalGitRef() { - if (existsSync("../../.git")) { + if (existsSync("../../.git/HEAD")) { const rev = readFileSync("../../.git/HEAD") .toString() .trim() diff --git a/apps/nextjs/scripts/increase-listeners.js b/apps/nextjs/scripts/increase-listeners.js deleted file mode 100644 index 7d9e0c25a..000000000 --- a/apps/nextjs/scripts/increase-listeners.js +++ /dev/null @@ -1,18 +0,0 @@ -/**** - Because we have a reasonably complex Next.js project now, - we're sometimes running into the default max listeners limit. - This script increases the limit to 20, which should be enough - so that we don't run into this issue. - - Potentially, if we decide to move to Turbopack for compilation - in local development, we could remove this script. - -***/ - -// Increase the limit of max listeners -require("events").EventEmitter.defaultMaxListeners = 20; - -// Run the original command -require("child_process").spawn(process.argv[2], process.argv.slice(3), { - stdio: "inherit", -}); diff --git a/apps/nextjs/scripts/preload-chat-routes.mjs b/apps/nextjs/scripts/preload-chat-routes.mjs index fee10aa64..de599b985 100644 --- a/apps/nextjs/scripts/preload-chat-routes.mjs +++ b/apps/nextjs/scripts/preload-chat-routes.mjs @@ -36,8 +36,13 @@ const preBuildRoutes = async ( .get(`http://localhost:2525${route}`, { headers }) .then(() => console.log(`Pre-built route: ${route}`)) .catch((error) => { - console.log(`Error pre-building route: ${route}`, error.message); - return Promise.reject(error); + const errorToLog = + error instanceof Error ? error : new Error(String(error)); + console.log( + `Error pre-building route: ${route}`, + errorToLog.message, + ); + return Promise.reject(errorToLog); }); } else { return axios({ @@ -51,12 +56,14 @@ const preBuildRoutes = async ( ), ) .catch((error) => { - console.log(error); + const errorToLog = + error instanceof Error ? error : new Error(String(error)); + console.log(errorToLog); console.log( `Error pre-building route: ${route.url}`, - error.message, + errorToLog.message, ); - return Promise.reject(error); + return Promise.reject(errorToLog); }); } }); @@ -68,6 +75,7 @@ const preBuildRoutes = async ( console.log("All routes pre-built successfully"); console.timeEnd(timerId); } catch (error) { + console.error(error); if (retryCount < maxRetries) { console.log( `Retrying pre-build (attempt ${retryCount + 1} of ${maxRetries})...`, diff --git a/apps/nextjs/src/ai-apps/quiz-designer/quizRequestGeneration.ts b/apps/nextjs/src/ai-apps/quiz-designer/quizRequestGeneration.ts index 5efddcb15..0918104d8 100644 --- a/apps/nextjs/src/ai-apps/quiz-designer/quizRequestGeneration.ts +++ b/apps/nextjs/src/ai-apps/quiz-designer/quizRequestGeneration.ts @@ -1,11 +1,14 @@ import type { GenerationPart } from "@oakai/core/src/types"; +import { aiLogger } from "@oakai/logger"; import { getAgesFromKeyStage } from "@/utils/getAgesFromKeyStage"; import { extraQuizPromptInfo } from "./extraQuizPromptInfo"; import type { QuizAppState, QuizAppStateQuestion } from "./state/types"; -type RequestionGenerationInputs = { +const logger = aiLogger("quiz"); + +export type RequestionGenerationInputs = { lastGenerationId: string | null; sessionId: string; factQuestion: string; @@ -26,14 +29,16 @@ type RequestionGenerationInputs = { }; }; -type QuizRequestGenerationProps = { +export type QuizRequestGenerationProps = { state: QuizAppState; questionRow: QuizAppStateQuestion; lastGeneration: GenerationPart | undefined; - requestGeneration: (requestionGenInputs: RequestionGenerationInputs) => void; + requestGeneration: ( + requestionGenInputs: RequestionGenerationInputs, + ) => Promise; }; -export function quizRequestGeneration({ +export async function quizRequestGeneration({ state, questionRow, requestGeneration, @@ -47,7 +52,7 @@ export function quizRequestGeneration({ state, questionRow, }); - requestGeneration({ + await requestGeneration({ lastGenerationId: lastGeneration?.lastGenerationId ?? null, sessionId, factQuestion: `${topic}: ${questionRow.question.value}`, @@ -68,5 +73,7 @@ export function quizRequestGeneration({ (distractor) => distractor.value, ), }, + }).catch((e) => { + logger.error(e); }); } diff --git a/apps/nextjs/src/app/actions.ts b/apps/nextjs/src/app/actions.ts index 7cc3bb9b5..b365ff4bc 100644 --- a/apps/nextjs/src/app/actions.ts +++ b/apps/nextjs/src/app/actions.ts @@ -43,7 +43,7 @@ export async function getChatById( id: string, ): Promise { const session = await prisma?.appSession.findUnique({ - where: { id }, + where: { id, deletedAt: null }, }); if (!session) { @@ -55,7 +55,7 @@ export async function getChatById( id, sessionOutput: session.output, userId: session.userId, - }) || null + }) ?? null ); } diff --git a/apps/nextjs/src/app/admin/aila/[chatId]/view.tsx b/apps/nextjs/src/app/admin/aila/[chatId]/view.tsx index 8b7174c78..5a2c43b14 100644 --- a/apps/nextjs/src/app/admin/aila/[chatId]/view.tsx +++ b/apps/nextjs/src/app/admin/aila/[chatId]/view.tsx @@ -7,7 +7,11 @@ import { OakAccordion, OakPrimaryButton } from "@oaknational/oak-components"; import { trpc } from "@/utils/trpc"; -function ModerationListItem({ moderation }: { readonly moderation: Moderation }) { +function ModerationListItem({ + moderation, +}: { + readonly moderation: Moderation; +}) { const { id, invalidatedAt } = moderation; const [invalidated, setInvalidated] = useState(Boolean(invalidatedAt)); const invalidateModeration = trpc.admin.invalidateModeration.useMutation({ @@ -28,7 +32,7 @@ function ModerationListItem({ moderation }: { readonly moderation: Moderation }) iconName="cross" className="ml-auto" onClick={() => - invalidateModeration.mutateAsync({ moderationId: id }) + void invalidateModeration.mutateAsync({ moderationId: id }) } isLoading={invalidateModeration.isLoading} disabled={!!invalidated} @@ -41,14 +45,16 @@ function ModerationListItem({ moderation }: { readonly moderation: Moderation }) {moderation.justification}
- {moderation.categories.map((category, index) => ( - - {String(category)} - - ))} + {Array.from(new Set(moderation.categories)) + .map((c) => String(c)) + .map((category) => ( + + {category} + + ))}
diff --git a/apps/nextjs/src/app/aila/[id]/download/DownloadView.stories.tsx b/apps/nextjs/src/app/aila/[id]/download/DownloadView.stories.tsx index 6021b1dfe..f61acc667 100644 --- a/apps/nextjs/src/app/aila/[id]/download/DownloadView.stories.tsx +++ b/apps/nextjs/src/app/aila/[id]/download/DownloadView.stories.tsx @@ -1,14 +1,17 @@ import type { AilaPersistedChat } from "@oakai/aila/src/protocol/schema"; import type { Meta, StoryObj } from "@storybook/react"; +import { chromaticParams } from "@/storybook/chromatic"; + import { DemoProvider } from "../../../../../src/components/ContextProviders/Demo"; import { DownloadContent } from "./DownloadView"; -const meta: Meta = { +const meta = { title: "Pages/Chat/Download", component: DownloadContent, parameters: { layout: "fullscreen", + ...chromaticParams(["mobile", "desktop"]), }, decorators: [ (Story) => ( @@ -17,10 +20,10 @@ const meta: Meta = { ), ], -}; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; const chat: AilaPersistedChat = { id: "nSLmbQ1LO75zLTcA", diff --git a/apps/nextjs/src/app/aila/[id]/download/DownloadView.tsx b/apps/nextjs/src/app/aila/[id]/download/DownloadView.tsx index 165e3a41b..5735bdc87 100644 --- a/apps/nextjs/src/app/aila/[id]/download/DownloadView.tsx +++ b/apps/nextjs/src/app/aila/[id]/download/DownloadView.tsx @@ -37,164 +37,162 @@ export function DownloadContent({ chat }: Readonly) { const isLessonComplete = totalSectionsComplete >= totalSections; return ( - <> - - - -
- - - + + + +
+ + + -
-

Download resources

-

- {isLessonComplete ? ( - - ) : ( - +

+

Download resources

+

+ {isLessonComplete ? ( + + ) : ( + + )} +

+
+ + + exportAllAssets.start()} + title="Download all resources" + subTitle="Lesson plan, starter and exit quiz, slides and worksheet" + downloadAvailable={!!exportAllAssets.readyToExport} + downloadLoading={exportAllAssets.status === "loading"} + data={exportAllAssets.data} + data-testid="chat-download-all-resources" + lesson={lessonPlan} + chatId={id} + /> + lessonPlanExport.start()} + title="Lesson plan" + subTitle="Overview of the complete lesson" + downloadAvailable={!!lessonPlanExport.readyToExport} + downloadLoading={lessonPlanExport.status === "loading"} + data={lessonPlanExport.data} + exportsType="lessonPlanDoc" + data-testid="chat-download-lesson-plan" + lesson={lessonPlan} + /> + starterQuizExport.start()} + title="Starter quiz" + subTitle="Questions and answers to assess prior knowledge" + downloadAvailable={!!starterQuizExport.readyToExport} + downloadLoading={starterQuizExport.status === "loading"} + data={starterQuizExport.data} + exportsType="starterQuiz" + lesson={lessonPlan} + /> + lessonSlidesExport.start()} + data-testid="chat-download-slides-btn" + title="Slide deck" + subTitle="Learning outcome, keywords and learning cycles" + downloadAvailable={lessonSlidesExport.readyToExport} + downloadLoading={lessonSlidesExport.status === "loading"} + data={lessonSlidesExport.data} + exportsType="lessonSlides" + lesson={lessonPlan} + /> + worksheetExport.start()} + title="Worksheet" + subTitle="Practice tasks" + downloadAvailable={!!worksheetExport.readyToExport} + downloadLoading={worksheetExport.status === "loading"} + data={worksheetExport.data} + lesson={lessonPlan} + exportsType="worksheet" + /> + exitQuizExport.start()} + title="Exit quiz" + subTitle="Questions and answers to assess understanding" + downloadAvailable={!!exitQuizExport.readyToExport} + downloadLoading={exitQuizExport.status === "loading"} + data={exitQuizExport.data} + exportsType="exitQuiz" + lesson={lessonPlan} + /> + {lessonPlan.additionalMaterials && + lessonPlan.additionalMaterials !== "None" && ( + additionalMaterialsExport.start()} + title="Additional materials" + subTitle="Document containing any additional materials" + downloadAvailable={ + !!additionalMaterialsExport.readyToExport + } + downloadLoading={ + additionalMaterialsExport.status === "loading" + } + data={additionalMaterialsExport.data} + exportsType="additionalMaterials" + lesson={lessonPlan} + /> )} -

-
- - - exportAllAssets.start()} - title="Download all resources" - subTitle="Lesson plan, starter and exit quiz, slides and worksheet" - downloadAvailable={!!exportAllAssets.readyToExport} - downloadLoading={exportAllAssets.status === "loading"} - data={exportAllAssets.data} - data-testid="chat-download-all-resources" - lesson={lessonPlan} - chatId={id} - /> - lessonPlanExport.start()} - title="Lesson plan" - subTitle="Overview of the complete lesson" - downloadAvailable={!!lessonPlanExport.readyToExport} - downloadLoading={lessonPlanExport.status === "loading"} - data={lessonPlanExport.data} - exportsType="lessonPlanDoc" - data-testid="chat-download-lesson-plan" - lesson={lessonPlan} - /> - starterQuizExport.start()} - title="Starter quiz" - subTitle="Questions and answers to assess prior knowledge" - downloadAvailable={!!starterQuizExport.readyToExport} - downloadLoading={starterQuizExport.status === "loading"} - data={starterQuizExport.data} - exportsType="starterQuiz" - lesson={lessonPlan} - /> - lessonSlidesExport.start()} - data-testid="chat-download-slides-btn" - title="Slide deck" - subTitle="Learning outcome, keywords and learning cycles" - downloadAvailable={lessonSlidesExport.readyToExport} - downloadLoading={lessonSlidesExport.status === "loading"} - data={lessonSlidesExport.data} - exportsType="lessonSlides" - lesson={lessonPlan} - /> - worksheetExport.start()} - title="Worksheet" - subTitle="Practice tasks" - downloadAvailable={!!worksheetExport.readyToExport} - downloadLoading={worksheetExport.status === "loading"} - data={worksheetExport.data} - lesson={lessonPlan} - exportsType="worksheet" - /> - exitQuizExport.start()} - title="Exit quiz" - subTitle="Questions and answers to assess understanding" - downloadAvailable={!!exitQuizExport.readyToExport} - downloadLoading={exitQuizExport.status === "loading"} - data={exitQuizExport.data} - exportsType="exitQuiz" - lesson={lessonPlan} - /> - {lessonPlan.additionalMaterials && - lessonPlan.additionalMaterials !== "None" && ( - additionalMaterialsExport.start()} - title="Additional materials" - subTitle="Document containing any additional materials" - downloadAvailable={ - !!additionalMaterialsExport.readyToExport - } - downloadLoading={ - additionalMaterialsExport.status === "loading" - } - data={additionalMaterialsExport.data} - exportsType="additionalMaterials" - lesson={lessonPlan} - /> - )} - - -
- - {`${totalSectionsComplete} of ${totalSections} sections complete`} - -
- {sections.map((section) => { - return ( + + +
+ + {`${totalSectionsComplete} of ${totalSections} sections complete`} + +
+ {sections.map((section) => { + return ( + - - - -

{section.label}

+
- ); - })} -
-
-
+

{section.label}

+ + ); + })} +
+ -
-
- +
+
+
); } diff --git a/apps/nextjs/src/app/aila/[id]/share/index.stories.tsx b/apps/nextjs/src/app/aila/[id]/share/index.stories.tsx index b8a803caf..4c4ba5945 100644 --- a/apps/nextjs/src/app/aila/[id]/share/index.stories.tsx +++ b/apps/nextjs/src/app/aila/[id]/share/index.stories.tsx @@ -1,18 +1,21 @@ import type { LooseLessonPlan } from "@oakai/aila/src/protocol/schema"; import type { Meta, StoryObj } from "@storybook/react"; +import { chromaticParams } from "@/storybook/chromatic"; + import ShareChat from "./"; -const meta: Meta = { +const meta = { title: "Pages/Chat/Share", component: ShareChat, parameters: { layout: "fullscreen", + ...chromaticParams(["mobile", "desktop"]), }, -}; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; const lessonPlan: LooseLessonPlan = { title: "The End of Roman Britain", diff --git a/apps/nextjs/src/app/aila/help/index.stories.tsx b/apps/nextjs/src/app/aila/help/index.stories.tsx index df567c777..20695aa7b 100644 --- a/apps/nextjs/src/app/aila/help/index.stories.tsx +++ b/apps/nextjs/src/app/aila/help/index.stories.tsx @@ -1,15 +1,17 @@ import type { Meta, StoryObj } from "@storybook/react"; import { DemoProvider } from "@/components/ContextProviders/Demo"; +import { chromaticParams } from "@/storybook/chromatic"; import { HelpContent } from "."; -const meta: Meta = { +const meta = { title: "Pages/Chat/Help", component: HelpContent, parameters: { // Including custom decorators changes the layout from fullscreen layout: "fullscreen", + ...chromaticParams(["mobile", "desktop"]), }, decorators: [ (Story) => ( @@ -18,10 +20,10 @@ const meta: Meta = { ), ], -}; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { args: {}, diff --git a/apps/nextjs/src/app/aila/help/index.tsx b/apps/nextjs/src/app/aila/help/index.tsx index 3646ae0a9..355ae6257 100644 --- a/apps/nextjs/src/app/aila/help/index.tsx +++ b/apps/nextjs/src/app/aila/help/index.tsx @@ -1,27 +1,40 @@ "use client"; -import { useRef } from "react"; +import { useRef, type MutableRefObject } from "react"; -import { OakLink } from "@oaknational/oak-components"; +import { + OakBox, + OakFlex, + OakGrid, + OakGridArea, + OakHeading, + OakLI, + OakLink, + OakMaxWidth, + OakP, + OakUL, +} from "@oaknational/oak-components"; import { useSearchParams } from "next/navigation"; -import { Header } from "@/components/AppComponents/Chat/header"; import GetInTouchBox from "@/components/AppComponents/GetInTouchBox"; +import Layout from "@/components/Layout"; export const HelpContent = () => { const startingRef = useRef(null); const structureRef = useRef(null); + const learningCycles = useRef(null); const downloadsRef = useRef(null); + const additionalMaterialsRef = useRef(null); const creatingRef = useRef(null); const reviewingRef = useRef(null); const downloadingRef = useRef(null); const aiRef = useRef(null); - const scrollToRefWithOffset = (ref) => { - if (ref && ref.current) { + const scrollToRefWithOffset = (ref: MutableRefObject) => { + if (ref?.current) { const yOffset = -72; // Adjust this value as needed const y = - ref.current.getBoundingClientRect().top + window.pageYOffset + yOffset; + ref.current.getBoundingClientRect().top + window.scrollY + yOffset; window.scrollTo({ top: y, behavior: "smooth" }); } }; @@ -30,204 +43,241 @@ export const HelpContent = () => { const ailaId = searchParams.get("ailaId"); return ( - <> -
-
-
-

Help

-
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
-
- -
-
+ + + + + + + + Help + + + + + scrollToRefWithOffset(startingRef)}> + Starting your first lesson + + + + scrollToRefWithOffset(structureRef)}> + Structure of an Oak lesson + + + + scrollToRefWithOffset(learningCycles)} + > + Learning cycles + + + + scrollToRefWithOffset(downloadsRef)}> + Downloads + + + + + scrollToRefWithOffset(additionalMaterialsRef) + } + > + Examples of additional materials + + + + scrollToRefWithOffset(creatingRef)}> + Co-creating your lesson + + + + scrollToRefWithOffset(reviewingRef)}> + Reviewing and editing + + + + scrollToRefWithOffset(downloadingRef)} + > + Downloading and sharing + + + + scrollToRefWithOffset(aiRef)}> + AI and accuracy + + + + + + + + + Back to Aila -
+ -
+ -
-

- Starting your first lesson -

-

- Create your first lesson by typing a lesson title and key stage - into the input box on the first screen and clicking the arrow key. - This will create the starting point of your lesson. You can get - back to the initial screen by clicking 'New lesson' in - the main navigation. -

-

- Structure of an Oak lesson -

-

- Lessons are created using Oak's curriculum principles and - include the following sections: -

-
    -
  • Learning outcome
  • -
  • Learning cycle outcomes
  • -
  • Prior knowledge
  • -
  • Key learning points
  • -
  • Misconceptions
  • -
  • Starter quiz
  • -
  • Learning cycles
  • -
  • Exit quiz
  • -
  • Additional materials (optional)
  • -
-

Learning cycles

-

Each learning cycle includes 4 key sections:

-
    -
  • Explanation
  • -
  • Checks for understanding
  • -
  • Practice
  • -
  • Feedback
  • -
-

- Downloads -

-

- Once the lesson has been created (and all sections are complete) - you will be able to download the following documents: -

-
    -
  • Lesson, including all sections (PDF/Docx file)
  • -
  • Starter quiz (PDF/Docx file)
  • -
  • Slides (PDF/PPTX file)
  • -
  • Exit quiz (PDF/Docx file)
  • -
-

- If you have a Google account, you will also be able to edit these - documents directly. -

-

- Examples of additional materials -

-

- Health and safety information, lesson narration, model answers or - additional practice tasks. -

-

- Co-creating your lesson -

-

- Aila, Oak's AI lesson assistant will guide you through the - process of co-creating each section of your lesson on the - left-hand side of the screen. The lesson output will be generated - on the right-hand side. -

-

- Reviewing and editing -

-

- Aila will pause at key points during the lesson creation and will - ask you to review the content generated. You can ask Aila to adapt - each section, for example: -

-
    -
  • adapt the content for a specific reading age.
  • -
  • - add specific case studies or examples to your explanations. -
  • -
  • change the practice tasks for you.
  • -
  • - add a narrative for each learning cycle to the Additional - Materials. -
  • -
-

- Downloading and sharing -

-

- When you have completed all the sections in the lesson, you will - be able to download the lesson resources, including the full - lesson plan (which includes all sections), editable slides and - starter and exit quizzes. You can also share your lesson as a - link, which can be viewed online. -

-

- AI and accuracy -

-

- Retrieval Augmented Generation is used to integrate Oak content - into your lessons, improving the accuracy of the content produced. - AI is not 100% accurate and mistakes can still be made. Remember, - you're still the expert, so please ensure all content is - reviewed before using it in a classroom or sharing. -

-
-
-
- + + + + Starting your first lesson + + + Create your first lesson by typing a lesson title and key stage + into the input box on the first screen and clicking the arrow + key. This will create the starting point of your lesson. You can + get back to the initial screen by clicking 'New + lesson' in the main navigation. + + + + + Structure of an Oak lesson + + + Lessons are created using Oak's curriculum principles and + include the following sections: + + + Lesson details + Learning outcome + Learning cycle outcomes + Prior knowledge + Key learning points + Misconceptions + Keywords + Starter quiz + Learning cycles + Exit quiz + Additional materials (optional) + + + + + Learning cycles + + + Each learning cycle includes 4 key sections: + + + Explanation + Checks for understanding + Practice + Feedback + + + + + Downloads + + + Once the lesson has been created (and all sections are complete) + you will be able to download the following documents: + + + Lesson plan, including all sections + Starter quiz (PDF/Docx file) + Slides (PDF/PPTX file) + Worksheet (PDF/Docx file) + Exit quiz (PDF/Docx file) + Additional materials (PDF/Docx file) + + + If you have a Google account, you will also be able to edit + these documents directly. + + + + + Examples of additional materials + + + Case study context sheet, an essay title with success criteria + for pupils to complete for homework, suggestions for alternative + art or equipment you can use for your lesson. + + + + + Co-creating your lesson + + + Aila, Oak's AI lesson assistant will guide you through the + process of co-creating each section of your lesson on the + left-hand side of the screen. The lesson output will be + generated on the right-hand side. + + + + + Reviewing and editing + + + Aila will pause at key points during the lesson creation and + will ask you to review the content generated. You can ask Aila + to adapt each section, for example: + + + + increase or decrease the literacy level of your keywords + + + add specific case studies or examples to your explanations + + change the practice tasks for you + + add a narrative for each learning cycle to the Additional + Materials + + + + + + Downloading and sharing + + + Once you’ve completed all the sections in the lesson, you’ll be + able to download the full set of lesson resources. This includes + the complete lesson plan (covering all sections), editable + slides, starter and exit quizzes, a worksheet with your practice + tasks, and any additional materials you’ve created. You can also + share your lesson via a link, allowing it to be viewed online. + + + + + AI and accuracy + + + Retrieval augmented generation is used to integrate Oak content + into your lessons, improving the accuracy of the content + produced. AI is not 100% accurate and mistakes can still be + made. Remember, you’re still the expert, so please ensure all + content is reviewed before using it in a classroom or sharing. + + + + + + ); }; -export default function HelpPage() { +export default function HelpPageView() { return ( - <> -
+ - + ); } diff --git a/apps/nextjs/src/app/aila/page-contents.tsx b/apps/nextjs/src/app/aila/page-contents.tsx index 49119a78d..8dce45b37 100644 --- a/apps/nextjs/src/app/aila/page-contents.tsx +++ b/apps/nextjs/src/app/aila/page-contents.tsx @@ -11,7 +11,7 @@ const ChatPageContents = ({ id }: { readonly id: string }) => { return ( - + diff --git a/apps/nextjs/src/app/api/aila-download-all/route.ts b/apps/nextjs/src/app/api/aila-download-all/route.ts index 37d46e7f6..cb4b8031d 100644 --- a/apps/nextjs/src/app/api/aila-download-all/route.ts +++ b/apps/nextjs/src/app/api/aila-download-all/route.ts @@ -1,6 +1,5 @@ import { auth } from "@clerk/nextjs/server"; -import type { LessonExportType } from "@oakai/db"; -import { prisma } from "@oakai/db"; +import { prisma, type LessonExportType } from "@oakai/db"; import { downloadDriveFile } from "@oakai/exports"; import * as Sentry from "@sentry/node"; import { kv } from "@vercel/kv"; @@ -9,6 +8,7 @@ import { PassThrough } from "stream"; import { withSentry } from "@/lib/sentry/withSentry"; +import { saveDownloadEvent } from "../aila-download/downloadHelpers"; import { sanitizeFilename } from "../sanitizeFilename"; type FileIdsAndFormats = { @@ -16,48 +16,6 @@ type FileIdsAndFormats = { formats: ReadonlyArray<"pptx" | "docx" | "pdf">; }[]; -function getReadableExportType(exportType: LessonExportType) { - switch (exportType) { - case "EXIT_QUIZ_DOC": - return "Exit quiz"; - case "LESSON_PLAN_DOC": - return "Lesson plan"; - case "STARTER_QUIZ_DOC": - return "Starter quiz"; - case "WORKSHEET_SLIDES": - return "Worksheet"; - case "LESSON_SLIDES_SLIDES": - return "Lesson slides"; - case "ADDITIONAL_MATERIALS_DOCS": - return "Additional materials"; - } -} - -async function saveDownloadEvent({ - lessonExportId, - downloadedBy, - ext, -}: { - lessonExportId: string; - downloadedBy: string; - ext: string; -}) { - try { - await prisma.lessonExportDownload.create({ - data: { - lessonExportId, - downloadedBy, - ext, - }, - }); - } catch (error) { - Sentry.captureException(error, { - level: "warning", - extra: { lessonExportId, downloadedBy, ext }, - }); - } -} - function nodePassThroughToReadableStream(passThrough: PassThrough) { return new ReadableStream({ start(controller) { @@ -77,6 +35,23 @@ function nodePassThroughToReadableStream(passThrough: PassThrough) { }); } +function getReadableExportType(exportType: LessonExportType) { + switch (exportType) { + case "EXIT_QUIZ_DOC": + return "Exit quiz"; + case "LESSON_PLAN_DOC": + return "Lesson plan"; + case "STARTER_QUIZ_DOC": + return "Starter quiz"; + case "WORKSHEET_SLIDES": + return "Worksheet"; + case "LESSON_SLIDES_SLIDES": + return "Lesson slides"; + case "ADDITIONAL_MATERIALS_DOCS": + return "Additional materials"; + } +} + async function getHandler(req: Request): Promise { const { searchParams } = new URL(req.url); const fileIdsParam = searchParams.get("fileIds"); diff --git a/apps/nextjs/src/app/api/aila-download/downloadHelpers.ts b/apps/nextjs/src/app/api/aila-download/downloadHelpers.ts new file mode 100644 index 000000000..7ce92b445 --- /dev/null +++ b/apps/nextjs/src/app/api/aila-download/downloadHelpers.ts @@ -0,0 +1,27 @@ +import { prisma } from "@oakai/db"; +import * as Sentry from "@sentry/node"; + +export async function saveDownloadEvent({ + lessonExportId, + downloadedBy, + ext, +}: { + lessonExportId: string; + downloadedBy: string; + ext: string; +}) { + try { + await prisma.lessonExportDownload.create({ + data: { + lessonExportId, + downloadedBy, + ext, + }, + }); + } catch (error) { + Sentry.captureException(error, { + level: "warning", + extra: { lessonExportId, downloadedBy, ext }, + }); + } +} diff --git a/apps/nextjs/src/app/api/aila-download/route.ts b/apps/nextjs/src/app/api/aila-download/route.ts index 64c9010bc..6177ba41e 100644 --- a/apps/nextjs/src/app/api/aila-download/route.ts +++ b/apps/nextjs/src/app/api/aila-download/route.ts @@ -1,12 +1,12 @@ import { auth } from "@clerk/nextjs/server"; -import type { LessonExportType } from "@oakai/db"; -import { prisma } from "@oakai/db"; +import { prisma, type LessonExportType } from "@oakai/db"; import { downloadDriveFile } from "@oakai/exports"; import * as Sentry from "@sentry/node"; import { withSentry } from "@/lib/sentry/withSentry"; import { sanitizeFilename } from "../sanitizeFilename"; +import { saveDownloadEvent } from "./downloadHelpers"; // From: https://www.ericburel.tech/blog/nextjs-stream-files async function* nodeStreamToIterator(stream: NodeJS.ReadableStream) { @@ -46,31 +46,6 @@ function getReadableExportType(exportType: LessonExportType) { } } -async function saveDownloadEvent({ - lessonExportId, - downloadedBy, - ext, -}: { - lessonExportId: string; - downloadedBy: string; - ext: string; -}) { - try { - await prisma.lessonExportDownload.create({ - data: { - lessonExportId, - downloadedBy, - ext, - }, - }); - } catch (error) { - Sentry.captureException(error, { - level: "warning", - extra: { lessonExportId, downloadedBy, ext }, - }); - } -} - async function getHandler(req: Request): Promise { const { searchParams } = new URL(req.url); @@ -135,7 +110,7 @@ async function getHandler(req: Request): Promise { }); } - saveDownloadEvent({ + await saveDownloadEvent({ lessonExportId: lessonExport.id, downloadedBy: userId, ext, diff --git a/apps/nextjs/src/app/api/chat/chatHandler.ts b/apps/nextjs/src/app/api/chat/chatHandler.ts index 7ab8bf78c..20f28706a 100644 --- a/apps/nextjs/src/app/api/chat/chatHandler.ts +++ b/apps/nextjs/src/app/api/chat/chatHandler.ts @@ -1,10 +1,10 @@ import type { Aila } from "@oakai/aila/src/core/Aila"; import type { AilaServices } from "@oakai/aila/src/core/AilaServices"; import type { Message } from "@oakai/aila/src/core/chat"; -import type { AilaInitializationOptions } from "@oakai/aila/src/core/types"; import type { AilaOptions, AilaPublicChatOptions, + AilaInitializationOptions, } from "@oakai/aila/src/core/types"; import { AilaAmericanisms } from "@oakai/aila/src/features/americanisms/AilaAmericanisms"; import { @@ -40,7 +40,7 @@ export const maxDuration = 300; const prisma: PrismaClientWithAccelerate = globalPrisma; export async function GET() { - return new Response("Chat API is working", { status: 200 }); + return Promise.resolve(new Response("Chat API is working", { status: 200 })); } async function setupChatHandler(req: NextRequest) { diff --git a/apps/nextjs/src/app/api/chat/config.ts b/apps/nextjs/src/app/api/chat/config.ts index 4f32fe969..c2aacf02b 100644 --- a/apps/nextjs/src/app/api/chat/config.ts +++ b/apps/nextjs/src/app/api/chat/config.ts @@ -19,9 +19,9 @@ export const defaultConfig: Config = { const webActionsPlugin = createWebActionsPlugin(globalPrisma); const createdAila = new Aila({ ...options, - plugins: [...(options.plugins || []), webActionsPlugin], + plugins: [...(options.plugins ?? []), webActionsPlugin], prisma: options.prisma ?? globalPrisma, - chat: options.chat || { + chat: options.chat ?? { id: nanoid(), userId: undefined, }, diff --git a/apps/nextjs/src/app/api/chat/errorHandling.test.ts b/apps/nextjs/src/app/api/chat/errorHandling.test.ts index c9f0ebbd3..09e9aa0a4 100644 --- a/apps/nextjs/src/app/api/chat/errorHandling.test.ts +++ b/apps/nextjs/src/app/api/chat/errorHandling.test.ts @@ -27,7 +27,6 @@ describe("handleChatException", () => { const span = { setTag: jest.fn() } as unknown as TracingSpan; const error = new AilaThreatDetectionError("user_abc", "test error"); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const prisma = {} as unknown as PrismaClientWithAccelerate; const response = await handleChatException( @@ -54,7 +53,6 @@ describe("handleChatException", () => { it("should return an error chat message", async () => { const span = { setTag: jest.fn() } as unknown as TracingSpan; const error = new AilaAuthenticationError("test error"); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const prisma = {} as unknown as PrismaClientWithAccelerate; const response = await handleChatException( @@ -84,7 +82,6 @@ describe("handleChatException", () => { 100, Date.now() + 3600 * 1000, ); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const prisma = {} as unknown as PrismaClientWithAccelerate; const response = await handleChatException( @@ -117,7 +114,6 @@ describe("handleChatException", () => { it("should return an error chat message", async () => { const span = { setTag: jest.fn() } as unknown as TracingSpan; const error = new UserBannedError("test error"); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const prisma = {} as unknown as PrismaClientWithAccelerate; const response = await handleChatException( diff --git a/apps/nextjs/src/app/api/chat/fixtures/FixtureRecordLLMService.ts b/apps/nextjs/src/app/api/chat/fixtures/FixtureRecordLLMService.ts index b928aff0d..2b7523f5b 100644 --- a/apps/nextjs/src/app/api/chat/fixtures/FixtureRecordLLMService.ts +++ b/apps/nextjs/src/app/api/chat/fixtures/FixtureRecordLLMService.ts @@ -9,7 +9,7 @@ const log = aiLogger("fixtures"); export class FixtureRecordLLMService implements LLMService { name = "FixtureRecordLLM"; - private _openAIService: OpenAIService; + private readonly _openAIService: OpenAIService; constructor( public fixtureName: string, diff --git a/apps/nextjs/src/app/api/chat/fixtures/FixtureRecordOpenAiClient.ts b/apps/nextjs/src/app/api/chat/fixtures/FixtureRecordOpenAiClient.ts index cb0d5d686..68d340390 100644 --- a/apps/nextjs/src/app/api/chat/fixtures/FixtureRecordOpenAiClient.ts +++ b/apps/nextjs/src/app/api/chat/fixtures/FixtureRecordOpenAiClient.ts @@ -3,7 +3,7 @@ import { createOpenAIClient } from "@oakai/core/src/llm/openai"; import { aiLogger } from "@oakai/logger"; import fs from "fs/promises"; import type OpenAI from "openai"; -import type { ChatCompletionCreateParamsNonStreaming } from "openai/resources"; +import type { ChatCompletionCreateParamsNonStreaming } from "openai/resources/index.mjs"; const log = aiLogger("fixtures"); diff --git a/apps/nextjs/src/app/api/chat/webActionsPlugin.ts b/apps/nextjs/src/app/api/chat/webActionsPlugin.ts index 08b5d547d..07afb0298 100644 --- a/apps/nextjs/src/app/api/chat/webActionsPlugin.ts +++ b/apps/nextjs/src/app/api/chat/webActionsPlugin.ts @@ -64,7 +64,7 @@ export const createWebActionsPlugin: PluginCreator = ( data: { chatId: aila.chatId || "Unknown", categories: moderation.categories as string[], - justification: moderation.justification || "Unknown", + justification: moderation.justification ?? "Unknown", }, }); diff --git a/apps/nextjs/src/app/api/cron-jobs/expired-exports/route.ts b/apps/nextjs/src/app/api/cron-jobs/expired-exports/route.ts index ff1e86245..c2b0ca4d5 100644 --- a/apps/nextjs/src/app/api/cron-jobs/expired-exports/route.ts +++ b/apps/nextjs/src/app/api/cron-jobs/expired-exports/route.ts @@ -1,3 +1,4 @@ +import type { drive_v3 } from "@googleapis/drive"; import { prisma } from "@oakai/db"; import { googleDrive } from "@oakai/exports/src/gSuite/drive/client"; import { aiLogger } from "@oakai/logger"; @@ -54,14 +55,13 @@ async function updateExpiredAtAndDelete(fileIds: string[]) { log.error(`Error processing file with gdriveFileId: ${id}`, error); failedIds.push(id); } - - if (failedIds.length > 0) { - const errorMessage = `Failed to process the following file IDs: ${failedIds.join( - ", ", - )}`; - log.error(errorMessage); - throw new Error(errorMessage); - } + } + if (failedIds.length > 0) { + const errorMessage = `Failed to process the following file IDs: ${failedIds.join( + ", ", + )}`; + log.error(errorMessage); + throw new Error(errorMessage); } } @@ -89,7 +89,7 @@ async function fetchExpiredExports({ }); const files = - res.data.files?.filter((file) => file.ownedByMe === true) || []; + res.data.files?.filter((file) => file.ownedByMe === true) ?? []; if (files.length === 0) { log.info( @@ -127,19 +127,35 @@ export async function GET(request: NextRequest) { return new Response("Unauthorized", { status: 401 }); } - const files = await fetchExpiredExports({ folderId, daysAgo: 14 }); + let files: drive_v3.Schema$File[] | null; + let hasMoreFiles = true; - if (!files || files.length === 0) { - return new Response("No expired files found", { status: 404 }); - } + while (hasMoreFiles) { + files = await fetchExpiredExports({ folderId, daysAgo: 14 }); + + if (!files || files.length === 0) { + log.info("No expired files found."); + hasMoreFiles = false; + break; + } - const validFileIds = files.map((file) => file.id).filter(isTruthy); + const validFileIds = files.map((file) => file.id).filter(isTruthy); - await updateExpiredAtAndDelete(validFileIds); + if (validFileIds.length === 0) { + log.info("No valid file IDs to process."); + hasMoreFiles = false; + break; + } + + await updateExpiredAtAndDelete(validFileIds); + } + + return new Response("All expired files processed successfully.", { + status: 200, + }); } catch (error) { Sentry.captureException(error); + log.error("An error occurred during the cron job execution:", error); return new Response("Internal Server Error", { status: 500 }); } - - return new Response(JSON.stringify({ success: true }), { status: 200 }); } diff --git a/apps/nextjs/src/app/api/cron-jobs/google-drive-size-quota/route.ts b/apps/nextjs/src/app/api/cron-jobs/google-drive-size-quota/route.ts new file mode 100644 index 000000000..8590fba0a --- /dev/null +++ b/apps/nextjs/src/app/api/cron-jobs/google-drive-size-quota/route.ts @@ -0,0 +1,114 @@ +import { + slackAiOpsNotificationChannelId, + slackWebClient, +} from "@oakai/core/src/utils/slack"; +import { googleDrive } from "@oakai/exports/src/gSuite/drive/client"; +import { aiLogger } from "@oakai/logger"; +import * as Sentry from "@sentry/node"; +import type { NextRequest } from "next/server"; + +const log = aiLogger("cron"); + +const requiredEnvVars = ["CRON_SECRET", "SLACK_AI_OPS_NOTIFICATION_CHANNEL_ID"]; + +requiredEnvVars.forEach((envVar) => { + if (!process.env[envVar]) { + throw new Error(`Environment variable ${envVar} is not set.`); + } +}); + +async function fetchDriveUsage() { + try { + const res = await googleDrive.about.get({ + fields: "storageQuota, user(emailAddress)", + }); + + const storageQuota = res.data.storageQuota; + const userEmail = res.data.user?.emailAddress; + + if (!storageQuota) { + throw new Error("Unable to fetch storage quota information."); + } + + const usage = { + limit: parseInt(storageQuota.limit ?? "0", 10), + usage: parseInt(storageQuota.usage ?? "0", 10), + userEmail, + }; + + log.info( + `Drive usage retrieved: ${usage.usage} bytes used of ${usage.limit} bytes total, ${userEmail}.`, + ); + + return usage; + } catch (error) { + log.error("Failed to fetch Google Drive usage details:", error); + throw error; + } +} + +async function checkDriveUsageThreshold(thresholdPercentage: number = 80) { + try { + const usage = await fetchDriveUsage(); + + if (usage.limit === 0) { + throw new Error("Storage limit is reported as zero, which is invalid."); + } + + const usagePercentage = (usage.usage / usage.limit) * 100; + + log.info( + `Drive usage percentage: ${usagePercentage.toFixed( + 2, + )}%. Threshold is set at ${thresholdPercentage}%.`, + ); + + if (usagePercentage > thresholdPercentage) { + const errorMessage = `Drive usage is at ${usagePercentage.toFixed( + 2, + )}% of the total limit, exceeding the threshold of ${thresholdPercentage}% : ${usage.userEmail}`; + log.error(errorMessage); + Sentry.captureMessage(errorMessage); + await slackWebClient.chat.postMessage({ + channel: slackAiOpsNotificationChannelId, + text: errorMessage, + }); + } + } catch (error) { + log.error("Error during Drive usage check:", error); + Sentry.captureException(error); + throw error; + } +} + +export async function GET(request: NextRequest) { + try { + const authHeader = request.headers.get("authorization"); + const cronSecret = process.env.CRON_SECRET; + + if (!cronSecret) { + log.error("Missing cron secret"); + return new Response("Missing cron secret", { status: 500 }); + } + + if (authHeader !== `Bearer ${cronSecret}`) { + log.error("Authorization failed. Invalid token."); + return new Response("Unauthorized", { status: 401 }); + } + + log.info("Starting Google Drive usage check..."); + + await checkDriveUsageThreshold(80); + + return new Response("Drive usage check completed successfully.", { + status: 200, + }); + } catch (error) { + log.error( + "An error occurred during the Drive usage check cron job:", + error, + ); + Sentry.captureException(error); + return new Response("Internal Server Error", { status: 500 }); + } +} diff --git a/apps/nextjs/src/app/faqs/index.stories.tsx b/apps/nextjs/src/app/faqs/index.stories.tsx index 7eef2c06a..937727546 100644 --- a/apps/nextjs/src/app/faqs/index.stories.tsx +++ b/apps/nextjs/src/app/faqs/index.stories.tsx @@ -1,14 +1,19 @@ import type { Meta, StoryObj } from "@storybook/react"; +import { chromaticParams } from "@/storybook/chromatic"; + import { FAQPageContent } from "."; -const meta: Meta = { +const meta = { title: "Pages/FAQs", component: FAQPageContent, -}; + parameters: { + ...chromaticParams(["mobile", "desktop"]), + }, +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { args: {}, diff --git a/apps/nextjs/src/app/faqs/index.tsx b/apps/nextjs/src/app/faqs/index.tsx index d311c0468..ab0e60f01 100644 --- a/apps/nextjs/src/app/faqs/index.tsx +++ b/apps/nextjs/src/app/faqs/index.tsx @@ -28,828 +28,817 @@ export const FAQPageContent = () => { const concernsRef = useRef(null); const dataRef = useRef(null); const scrollToRefWithOffset = (ref) => { - if (ref && ref.current) { + if (ref?.current) { const yOffset = -72; // Adjust this value as needed const y = - ref.current.getBoundingClientRect().top + window.pageYOffset + yOffset; + ref.current.getBoundingClientRect().top + window.scrollY + yOffset; window.scrollTo({ top: y, behavior: "smooth" }); } }; return ( - <> - - - - - - FAQs - - - - - - scrollToRefWithOffset(startingRef)}> - Getting started - - - - scrollToRefWithOffset(featuresRef)}> - Features and functionality + + + + + + FAQs + + + + + + scrollToRefWithOffset(startingRef)}> + Getting started + + + + scrollToRefWithOffset(featuresRef)}> + Features and functionality + + + + scrollToRefWithOffset(supportRef)}> + Support and assistance + + + + scrollToRefWithOffset(accessibilityRef)}> + Accessibility + + + + scrollToRefWithOffset(usageRef)}> + Usage and best practices + + + + scrollToRefWithOffset(technicalRef)}> + Technical + + + + scrollToRefWithOffset(updatesRef)}> + Updates and enhancements + + + + scrollToRefWithOffset(concernsRef)}> + Other concerns + + + + scrollToRefWithOffset(dataRef)}> + Data privacy and security + + + + + + +
+ +
+ +
+ + Getting started + +
+ +
+ + How do I sign up for Aila? + + + You can sign up to access Aila and our other AI experiments{" "} + here. + + + How much does it cost to use Aila? + + + As with all of Oak's resources, Aila is completely free to + use. There are some fair use limits that we have applied that you + may reach if your use appears to be excessive. + + + What are the system requirements for using Aila? + + + We aim to make our resources as accessible as possible. Aila will + be available to any teacher with access to a laptop or computer + and the Internet. We currently don't support mobile usage. + + + What is a beta product? + + + Aila is in the beta phase, and we are still actively testing and + developing Aila. This phase helps identify issues by allowing + teachers to use the Aila in real-life conditions. Aila is not + perfect, and will make some mistakes!{" "} + + Your feedback is essential + {" "} + to refine and improve Aila. + + + Is there a tutorial or guide available to help me get started? + + + Aila provides helpful tips and prompts for getting started. If you + need additional support, you can take a look at our{" "} + + help + {" "} + section, or contact us. + +
+ +
+ + Features and functionality + +
+ +
+ + What features does Aila offer? + + + Aila offers a range of features to help teachers create + high-quality lesson plans and resources. It is designed to ensure + that lesson plans meet national curriculum standards. You can edit + your lesson plan using Aila, quickly generating downloadable + lesson plans, editable slide decks, and starter and exit quizzes. + You may also choose to adapt or add to the resources after + download. + + + Can I customise lessons? + + + Yes! Aila is designed to guide you through the process of + co-creating a lesson. The more you specify to Aila, the better + your lesson will be. You can ask Aila to adapt the lesson to suit + your geographical context, the reading age of students in your + class, or any other additional needs your pupils may have. The + materials you produce with Aila are also fully editable. + + + What are the advantages of using Aila? + + + Aila has been based upon Oak's{" "} + + curriculum principles + {" "} + Aila is designed to take you through the process that an expert + teacher would use to plan a lesson; starting with your lesson + outcome and breaking that down into manageable chunks of learning, + our learning cycles. + + + It encourages you to think about the prior knowledge that students + will require for the lesson, the vocabulary that will need to be + explicitly taught during the lesson, the key knowledge that you + want pupils to take from the lesson and the common misconceptions + or errors that pupils make in this topic. + + + Our learning cycles are designed to ensure clear explanations - we + provide image and slide text suggestions to allow for dual coding, + we've ensured slide design minimises extraneous cognitive + load for students and built-in regular checks for understanding + tasks followed by lots of practice for students. + + + The process is designed to be flexible. You can tweak and change + the lesson as it is being created to suit the needs of your + pupils, as you know them best! The beauty of Aila is that it + won't produce your generic off-the-shelf lesson; it will help + you craft a lesson that is accessible and appropriate for your + students. + + + Aila uses a technique called retrieval augmented generation (RAG) + to incorporate the high-quality Oak content, which has been + carefully and expertly created by real-life teachers and + quality-checked by subject experts. This should improve the + accuracy of the lessons being produced. Aila has been designed for + UK teachers so you are less likely to see Americanisms and the + content will be more closely aligned with the English national + curriculum. + + + Why has Oak created Aila when other AI assistants are already + available? + + + Aila draws on existing Oak content, which has been carefully and + expertly created by real-life teachers and quality-checked by + subject experts. Aila has been designed around the Oak{" "} + + curriculum principles + + , which are national curriculum aligned and geared towards UK + users. With Aila, you're less likely to see Americanisms, and + the content will align much more closely with the requirements of + teachers in England. + + + What are the limitations of Aila's features? + + + We know that images are really important to support explanations + and Aila doesn't currently produce images or diagrams. Image + production is a feature that will come with a future iteration of + Aila, but to help teachers with this aspect of lesson design, we + currently provide an 'image suggestion' to help you find + an appropriate image with Google. + + + + We are aware that complex concepts may require more than one slide + to support their explanation. We are developing this feature but + think this is the part of a lesson that will need the most + development from a teacher after export. + + + + Large Language Models are not as good at producing high-quality + content for some subjects and this is a limitation of Aila. We + know that the output isn't as good in certain subjects yet, + in particular STEM subjects and modern foreign languages. We are + actively working to improve these subjects. + + + Why does Aila only output in Oak format? + + + When designing Aila, we wanted pedagogical rigour to be at its + core. Oak's lesson design is underpinned by research in + learning and cognitive science, which has formed the structure of + the lessons that Aila produces. All outputs are fully editable, so + you can update them to your preferred formats. + + + Why is there only one type of questioning in the quizzes? + + + Multiple-choice questions are a really effective way of quickly + assessing pupils' mastery of content, but they take a really + long time to write, so we have included lots to save you time. + After export, you can edit these to remove the options and make + them short answer questions or even add additional question types + to your slides. + + + Aila is still very much in development and we're aiming to + add more question types in future. + +
+ +
+ + Support and assistance + +
+ +
+ + Is support available if I encounter any issues or have questions? + + + + Yes, we provide comprehensive support to assist with any issues or + questions users may have. You can contact us via{" "} + email. + + + Can I provide feedback or suggest improvements for Aila? + + + Yes, please do! We love to hear how users are finding our + resources and how we can improve them. Submit your{" "} + + feedback or suggestions{" "} + + . + + + + Are there resources available for troubleshooting common problems? + + + The more feedback we receive from users, the more we can identify + common problems and provide troubleshooting tips. + +
+ +
+ + Accessibility + +
+ +
+ + Are there features in place to support diverse learners and + educators? + + + We want as many people as possible to be able to use Aila. You + should be able to; change colours, contrast levels, and fonts; + read and use most of the website while zoomed in up to 400%; + navigate most of the website using just a keyboard; navigate most + of the website using speech recognition software; listen to most + of the website using a screen reader (including the most recent + versions of JAWS, NVDA, and VoiceOver). View our{" "} + + full accessibility statement + + . + + + Can lesson plans be adapted for students with SEND? + + + Of course! You can prompt Aila to factor in your pupils' + needs when generating your lesson plan. Try asking Aila to produce + texts of different reading ages, sentence starters, or alternative + activities to support pupils during the additional materials + section of the lesson. You are also able to download and edit all + resources after you have created your lesson. + +
+ +
+ + Usage and best practices + +
+ +
+ + What are some best practices for maximising the effectiveness of + Aila? + + + Work with Aila to co-create your lesson. Before you proceed to the + next steps of co-creating your lesson with Aila, it is important + to check that you are happy with your lesson outcomes and learning + cycle outcomes, as this will determine the content for the rest of + the lesson. + + + If you want the content to be adapted for a specific geographical + context, reading age or a specific pupil need, tell Aila at the + start so that that is taken into consideration when designing the + lesson content. + + + The additional materials section of the lesson is the most + flexible. If you would like teacher instructions for a practical + lesson, model answers, essay-style questions, narratives for your + explanations or texts differentiated by reading age for your + classes, just ask at the end of the lesson planning process and + Aila will create these for you. + +
+ +
+ + Technical + +
+ +
+ + How does Aila work? + + + Aila is built to use Chat GPT4, but we are also evaluating other + models. We have written a 9,000-word prompt that provides very + specific guidance on what excellence looks like for each section + of a lesson. + + + Aila also uses retrieval augmented generation (RAG) to integrate + the high-quality content of Oak's human-planned lessons into + the lessons being delivered. This means that the accuracy of the + content produced should be closely aligned with the needs of + teachers in England. + +
+ +
+ + Updates and enhancements + +
+ +
+ + Are there plans for future enhancements or new features? + + + Yes, we strive to constantly improve our resources, taking into + account feedback from our users - Aila is still very much in + development. We aim to release future iterations of Aila that + produce images and diagrams. We are also developing Aila to ensure + that it has pedagogical rigour for a range of different subjects. + + + Can users suggest features for future updates? + + + Of course, we would love to hear your thoughts and suggestions for + other features that would support your teaching. Please{" "} + + give us your feedback! + + + + + How often are updates released for Aila? + + + We're still in the beta phase, which means that whilst + we're allowing teachers to use it, it's still prone to + bugs and errors, and is still under constant development. + We're constantly receiving feedback and aim to improve and + iterate on an almost daily basis! + + + + How will Aila be evaluated? + + + The team at Oak constantly evaluates Aila, and we are designing + multiple approaches to this. We carry out consistency checks and + ensure that it follows the instructions we have built into the + prompt, and evaluate the quality of content being produced. + + + We aim to evaluate several factors, such as Americanisms, cultural + biases, the appropriateness of the literacy level, checking that + the learning cycles increase in difficulty, and more. + + + Nonetheless, generative AI will make mistakes, and the outputs + should be checked carefully. + +
+ +
+ + Other concerns + +
+ +
+ + Is Oak trying to replace teachers with AI? + + + Absolutely not! At Oak, we share the brilliance of teachers from + across the country through our resources, and our Aila is no + different. It is designed to keep teachers in the driving seat, + with support from AI to reduce their workload. + +
+ +
+ + Data privacy and security + +
+ +
+ + Is Aila safe to use? + + + Generative AI will make mistakes, and no two outputs are the same. + With that in mind, it's important to check any AI-generated + content thoroughly before using it in the classroom. We have put + in place a number of technical and policy measures to minimise the + risks that generative AI presents but care should still be taken, + for example, you must not input personally identifiable + information into Aila, and this is in breach of the terms and + conditions of use. + + + + Can I have unlimited use? + + + In order to prevent misuse, we've restricted and protected + the volume of requests that can be made, lessons, and resources + that can be generated. If you're reaching these limits, + we'd love to hear from you, and you can{" "} + + request a higher limit. + + + + + What ethical measures are you putting in place? + + + Generative AI is a new and cutting-edge technology, and it's + important that anyone developing AI products actively takes into + account privacy, security and ethical considerations. We have + looked into emerging guidance and governance on generative AI, + including; + + + + UNESCO's core principles on{" "} + + a human-rights centred approach to the Ethics of AI - - scrollToRefWithOffset(supportRef)}> - Support and assistance + + US office for Edtech;{" "} + + Artificial Intelligence and the Future of Teaching and + Learning - - scrollToRefWithOffset(accessibilityRef)} - > - Accessibility + + UK DfE position on{" "} + + Generative artificial intelligence (AI) in education - - scrollToRefWithOffset(usageRef)}> - Usage and best practices - + + Aila is designed to: + + + + keep the human in the loop, informing and involving expert + teachers and educators in the process + - scrollToRefWithOffset(technicalRef)}> - Technical - + + not involve the input of personal information to LLMs + - scrollToRefWithOffset(updatesRef)}> - Updates and enhancements - + + allow us to evaluate cultural biases that may be present in + the generated content + - scrollToRefWithOffset(concernsRef)}> - Other concerns - + + create guardrails around the generative AI to improve + pedagogical alignment + - scrollToRefWithOffset(dataRef)}> - Data privacy and security - + + be transparent and explainable to users; we openly share the + underlying prompts + - - - -
- -
- -
- - Getting started - -
- -
- - How do I sign up for Aila? - - - You can sign up to access Aila and our other AI experiments{" "} - here. - - - How much does it cost to use Aila? - - - As with all of Oak's resources, Aila is completely free to - use. There are some fair use limits that we have applied that - you may reach if your use appears to be excessive. - - - What are the system requirements for using Aila? - - - We aim to make our resources as accessible as possible. Aila - will be available to any teacher with access to a laptop or - computer and the Internet. We currently don't support - mobile usage. - - - What is a beta product? - - - Aila is in the beta phase, and we are still actively testing and - developing Aila. This phase helps identify issues by allowing - teachers to use the Aila in real-life conditions. Aila is not - perfect, and will make some mistakes!{" "} - - Your feedback is essential - {" "} - to refine and improve Aila. - - - Is there a tutorial or guide available to help me get started? - - - Aila provides helpful tips and prompts for getting started. If - you need additional support, you can take a look at our{" "} - - help - {" "} - section, or contact us. - -
- -
- - Features and functionality - -
- -
- - What features does Aila offer? - - - Aila offers a range of features to help teachers create - high-quality lesson plans and resources. It is designed to - ensure that lesson plans meet national curriculum standards. You - can edit your lesson plan using Aila, quickly generating - downloadable lesson plans, editable slide decks, and starter and - exit quizzes. You may also choose to adapt or add to the - resources after download. - - - Can I customise lessons? - - - Yes! Aila is designed to guide you through the process of - co-creating a lesson. The more you specify to Aila, the better - your lesson will be. You can ask Aila to adapt the lesson to - suit your geographical context, the reading age of students in - your class, or any other additional needs your pupils may have. - The materials you produce with Aila are also fully editable. - - - What are the advantages of using Aila? - - - Aila has been based upon Oak's{" "} - - curriculum principles - {" "} - Aila is designed to take you through the process that an expert - teacher would use to plan a lesson; starting with your lesson - outcome and breaking that down into manageable chunks of - learning, our learning cycles. - - - It encourages you to think about the prior knowledge that - students will require for the lesson, the vocabulary that will - need to be explicitly taught during the lesson, the key - knowledge that you want pupils to take from the lesson and the - common misconceptions or errors that pupils make in this topic. - - - Our learning cycles are designed to ensure clear explanations - - we provide image and slide text suggestions to allow for dual - coding, we've ensured slide design minimises extraneous - cognitive load for students and built-in regular checks for - understanding tasks followed by lots of practice for students. - - - The process is designed to be flexible. You can tweak and change - the lesson as it is being created to suit the needs of your - pupils, as you know them best! The beauty of Aila is that it - won't produce your generic off-the-shelf lesson; it will - help you craft a lesson that is accessible and appropriate for - your students. - - - Aila uses a technique called retrieval augmented generation - (RAG) to incorporate the high-quality Oak content, which has - been carefully and expertly created by real-life teachers and - quality-checked by subject experts. This should improve the - accuracy of the lessons being produced. Aila has been designed - for UK teachers so you are less likely to see Americanisms and - the content will be more closely aligned with the English - national curriculum. - - - Why has Oak created Aila when other AI assistants are already - available? - - - Aila draws on existing Oak content, which has been carefully and - expertly created by real-life teachers and quality-checked by - subject experts. Aila has been designed around the Oak{" "} - - curriculum principles - - , which are national curriculum aligned and geared towards UK - users. With Aila, you're less likely to see Americanisms, - and the content will align much more closely with the - requirements of teachers in England. - - - What are the limitations of Aila's features? - - - We know that images are really important to support explanations - and Aila doesn't currently produce images or diagrams. - Image production is a feature that will come with a future - iteration of Aila, but to help teachers with this aspect of - lesson design, we currently provide an 'image - suggestion' to help you find an appropriate image with - Google. - - - - We are aware that complex concepts may require more than one - slide to support their explanation. We are developing this - feature but think this is the part of a lesson that will need - the most development from a teacher after export. - - - - Large Language Models are not as good at producing high-quality - content for some subjects and this is a limitation of Aila. We - know that the output isn't as good in certain subjects yet, - in particular STEM subjects and modern foreign languages. We are - actively working to improve these subjects. - - - Why does Aila only output in Oak format? - - - When designing Aila, we wanted pedagogical rigour to be at its - core. Oak's lesson design is underpinned by research in - learning and cognitive science, which has formed the structure - of the lessons that Aila produces. All outputs are fully - editable, so you can update them to your preferred formats. - - - Why is there only one type of questioning in the quizzes? - - - Multiple-choice questions are a really effective way of quickly - assessing pupils' mastery of content, but they take a - really long time to write, so we have included lots to save you - time. After export, you can edit these to remove the options and - make them short answer questions or even add additional question - types to your slides. - - - Aila is still very much in development and we're aiming to - add more question types in future. - -
- -
- - Support and assistance - -
- -
- - Is support available if I encounter any issues or have - questions? - - - - Yes, we provide comprehensive support to assist with any issues - or questions users may have. You can contact us via{" "} - email. - - - Can I provide feedback or suggest improvements for Aila? - - - Yes, please do! We love to hear how users are finding our - resources and how we can improve them. Submit your{" "} - - feedback or suggestions{" "} - - . - - - - Are there resources available for troubleshooting common - problems? - - - The more feedback we receive from users, the more we can - identify common problems and provide troubleshooting tips. - -
- -
- - Accessibility - -
- -
- - Are there features in place to support diverse learners and - educators? - - - We want as many people as possible to be able to use Aila. You - should be able to; change colours, contrast levels, and fonts; - read and use most of the website while zoomed in up to 400%; - navigate most of the website using just a keyboard; navigate - most of the website using speech recognition software; listen to - most of the website using a screen reader (including the most - recent versions of JAWS, NVDA, and VoiceOver). View our{" "} - - full accessibility statement - - . - - - Can lesson plans be adapted for students with SEND? - - - Of course! You can prompt Aila to factor in your pupils' - needs when generating your lesson plan. Try asking Aila to - produce texts of different reading ages, sentence starters, or - alternative activities to support pupils during the additional - materials section of the lesson. You are also able to download - and edit all resources after you have created your lesson. - -
- -
- - Usage and best practices - -
- -
- - What are some best practices for maximising the effectiveness of - Aila? - - - Work with Aila to co-create your lesson. Before you proceed to - the next steps of co-creating your lesson with Aila, it is - important to check that you are happy with your lesson outcomes - and learning cycle outcomes, as this will determine the content - for the rest of the lesson. - - - If you want the content to be adapted for a specific - geographical context, reading age or a specific pupil need, tell - Aila at the start so that that is taken into consideration when - designing the lesson content. - - - The additional materials section of the lesson is the most - flexible. If you would like teacher instructions for a practical - lesson, model answers, essay-style questions, narratives for - your explanations or texts differentiated by reading age for - your classes, just ask at the end of the lesson planning process - and Aila will create these for you. - -
- -
- - Technical - -
- -
- - How does Aila work? - - - Aila is built to use Chat GPT4, but we are also evaluating other - models. We have written a 9,000-word prompt that provides very - specific guidance on what excellence looks like for each section - of a lesson. - - - Aila also uses retrieval augmented generation (RAG) to integrate - the high-quality content of Oak's human-planned lessons - into the lessons being delivered. This means that the accuracy - of the content produced should be closely aligned with the needs - of teachers in England. - -
- -
- - Updates and enhancements - -
- -
- - Are there plans for future enhancements or new features? - - - Yes, we strive to constantly improve our resources, taking into - account feedback from our users - Aila is still very much in - development. We aim to release future iterations of Aila that - produce images and diagrams. We are also developing Aila to - ensure that it has pedagogical rigour for a range of different - subjects. - - - Can users suggest features for future updates? - - - Of course, we would love to hear your thoughts and suggestions - for other features that would support your teaching. Please{" "} - - give us your feedback! - - - - - How often are updates released for Aila? - - - We're still in the beta phase, which means that whilst - we're allowing teachers to use it, it's still prone to - bugs and errors, and is still under constant development. - We're constantly receiving feedback and aim to improve and - iterate on an almost daily basis! - - - - How will Aila be evaluated? - - - The team at Oak constantly evaluates Aila, and we are designing - multiple approaches to this. We carry out consistency checks and - ensure that it follows the instructions we have built into the - prompt, and evaluate the quality of content being produced. - - - We aim to evaluate several factors, such as Americanisms, - cultural biases, the appropriateness of the literacy level, - checking that the learning cycles increase in difficulty, and - more. - - - Nonetheless, generative AI will make mistakes, and the outputs - should be checked carefully. - -
- -
- - Other concerns - -
- -
- - Is Oak trying to replace teachers with AI? - - - Absolutely not! At Oak, we share the brilliance of teachers from - across the country through our resources, and our Aila is no - different. It is designed to keep teachers in the driving seat, - with support from AI to reduce their workload. - -
- -
- - Data privacy and security - -
- -
- - Is Aila safe to use? - - - Generative AI will make mistakes, and no two outputs are the - same. With that in mind, it's important to check any - AI-generated content thoroughly before using it in the - classroom. We have put in place a number of technical and policy - measures to minimise the risks that generative AI presents but - care should still be taken, for example, you must not input - personally identifiable information into Aila, and this is in - breach of the terms and conditions of use. - - - - Can I have unlimited use? - - - In order to prevent misuse, we've restricted and protected - the volume of requests that can be made, lessons, and resources - that can be generated. If you're reaching these limits, - we'd love to hear from you, and you can{" "} - - request a higher limit. - - - - - What ethical measures are you putting in place? - - - Generative AI is a new and cutting-edge technology, and - it's important that anyone developing AI products actively - takes into account privacy, security and ethical considerations. - We have looked into emerging guidance and governance on - generative AI, including; - - - - UNESCO's core principles on{" "} - - a human-rights centred approach to the Ethics of AI - - - - US office for Edtech;{" "} - - Artificial Intelligence and the Future of Teaching and - Learning - - - - UK DfE position on{" "} - - Generative artificial intelligence (AI) in education - - - - Aila is designed to: - - - - keep the human in the loop, informing and involving expert - teachers and educators in the process - - - - - not involve the input of personal information to LLMs - - - - - allow us to evaluate cultural biases that may be present in - the generated content - - - - - create guardrails around the generative AI to improve - pedagogical alignment - - - - - be transparent and explainable to users; we openly share the - underlying prompts - - - -
- -
- - Aila - -
- -
- - Why did you decide to name Oak's AI lesson assistant, Aila? - - - The name is an acronym for 'AI lesson assistant'. We - wanted the name to have 'AI' in it, as our research - found that teachers would be more likely to use an AI product if - it was clear that it was AI-powered and could save them time. - Further research into the name led to some deeper connections - which helped to solidify our decision. 'Aila' means - 'oak tree' in Hebrew, and in Scottish Gaelic, Aila - means 'from the strong place'. We believe the rigour - and quality of Aila stems from the strong foundation provided by - both Oak's strong curriculum principles and the - high-quality, teacher-generated content that we have been able - to integrate into the lesson development process. - - - Why did you give it a human name? - - - In Aila's initial testing phases, users reported being - unsure of how to 'talk' to the assistant. Giving it a - name humanises the chatbot and encourages more natural - conversation. - -
-
- - - +
+ +
+ + Aila + +
+ +
+ + Why did you decide to name Oak's AI lesson assistant, Aila? + + + The name is an acronym for 'AI lesson assistant'. We + wanted the name to have 'AI' in it, as our research + found that teachers would be more likely to use an AI product if + it was clear that it was AI-powered and could save them time. + Further research into the name led to some deeper connections + which helped to solidify our decision. 'Aila' means + 'oak tree' in Hebrew, and in Scottish Gaelic, Aila means + 'from the strong place'. We believe the rigour and + quality of Aila stems from the strong foundation provided by both + Oak's strong curriculum principles and the high-quality, + teacher-generated content that we have been able to integrate into + the lesson development process. + + + Why did you give it a human name? + + + In Aila's initial testing phases, users reported being unsure + of how to 'talk' to the assistant. Giving it a name + humanises the chatbot and encourages more natural conversation. + +
+
+
+
); }; diff --git a/apps/nextjs/src/app/global-error.tsx b/apps/nextjs/src/app/global-error.tsx index ce7e1baaa..4ac61f440 100644 --- a/apps/nextjs/src/app/global-error.tsx +++ b/apps/nextjs/src/app/global-error.tsx @@ -18,7 +18,7 @@ export default function GlobalError({ }, [error]); return ( - + diff --git a/apps/nextjs/src/app/home-page.stories.tsx b/apps/nextjs/src/app/home-page.stories.tsx index e61e5eab1..f4db6a63e 100644 --- a/apps/nextjs/src/app/home-page.stories.tsx +++ b/apps/nextjs/src/app/home-page.stories.tsx @@ -1,14 +1,19 @@ import type { Meta, StoryObj } from "@storybook/react"; +import { chromaticParams } from "@/storybook/chromatic"; + import { HomePageContent } from "./home-page"; -const meta: Meta = { +const meta = { title: "Pages/Homepage", component: HomePageContent, -}; + parameters: { + ...chromaticParams(["mobile", "desktop"]), + }, +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { args: { diff --git a/apps/nextjs/src/app/legal/[slug]/legal.stories.tsx b/apps/nextjs/src/app/legal/[slug]/legal.stories.tsx index 88a049b92..087a95fcf 100644 --- a/apps/nextjs/src/app/legal/[slug]/legal.stories.tsx +++ b/apps/nextjs/src/app/legal/[slug]/legal.stories.tsx @@ -1,14 +1,19 @@ import type { Meta, StoryObj } from "@storybook/react"; +import { chromaticParams } from "@/storybook/chromatic"; + import { LegalContent } from "./legal"; -const meta: Meta = { +const meta = { title: "Pages/Legal/Sanity dynamic", component: LegalContent, -}; + parameters: { + ...chromaticParams(["mobile", "desktop"]), + }, +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; const fixture = { pageData: { diff --git a/apps/nextjs/src/app/legal/account-locked/account-locked.stories.tsx b/apps/nextjs/src/app/legal/account-locked/account-locked.stories.tsx index 820cdd4e9..d3802b627 100644 --- a/apps/nextjs/src/app/legal/account-locked/account-locked.stories.tsx +++ b/apps/nextjs/src/app/legal/account-locked/account-locked.stories.tsx @@ -1,14 +1,19 @@ import type { Meta, StoryObj } from "@storybook/react"; +import { chromaticParams } from "@/storybook/chromatic"; + import { AccountLocked } from "./account-locked"; -const meta: Meta = { +const meta = { title: "Pages/Legal/Account Locked", component: AccountLocked, -}; + parameters: { + ...chromaticParams(["mobile", "desktop"]), + }, +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { args: {}, diff --git a/apps/nextjs/src/app/lesson-planner/preview/[slug]/page.tsx b/apps/nextjs/src/app/lesson-planner/preview/[slug]/page.tsx index fcced6393..493eda990 100644 --- a/apps/nextjs/src/app/lesson-planner/preview/[slug]/page.tsx +++ b/apps/nextjs/src/app/lesson-planner/preview/[slug]/page.tsx @@ -16,8 +16,6 @@ async function getData(slug: string) { notFound: true, }; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - // const { updatedAt, createdAt, ...rest } = sharedData; const planSections = sharedData.content; return planSections; diff --git a/apps/nextjs/src/app/prompts/prompts.stories.tsx b/apps/nextjs/src/app/prompts/prompts.stories.tsx index df2e3d10e..9fdb74c13 100644 --- a/apps/nextjs/src/app/prompts/prompts.stories.tsx +++ b/apps/nextjs/src/app/prompts/prompts.stories.tsx @@ -1,14 +1,19 @@ import type { Meta, StoryObj } from "@storybook/react"; +import { chromaticParams } from "@/storybook/chromatic"; + import { PromptsContent } from "./prompts"; -const meta: Meta = { +const meta = { title: "Pages/Prompts", component: PromptsContent, -}; + parameters: { + ...chromaticParams(["mobile", "desktop"]), + }, +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; const fixture = { apps: [ diff --git a/apps/nextjs/src/app/prompts/prompts.tsx b/apps/nextjs/src/app/prompts/prompts.tsx index c358e617f..dbefb5ebc 100644 --- a/apps/nextjs/src/app/prompts/prompts.tsx +++ b/apps/nextjs/src/app/prompts/prompts.tsx @@ -48,89 +48,87 @@ export const PromptsContent = ({ apps }: PromptsPageData) => { }, [pathname, itemRefs]); return ( - <> - - - - - How does our AI work? - - - At Oak’s AI Experiments, we aim to test whether high quality - education content can be generated using existing Large Language - Models (LLMs). We are keen to make sure that our work is - transparent. All our code across Oak is open source, and the repo - for our AI tools can be found{" "} - - here - - . - - - - - - As an example, here is the prompt that we use for our quiz builder - tool: + + + + + How does our AI work? + + At Oak’s AI Experiments, we aim to test whether high quality + education content can be generated using existing Large Language + Models (LLMs). We are keen to make sure that our work is + transparent. All our code across Oak is open source, and the repo + for our AI tools can be found{" "} + + here + + {"."} + + + + + + As an example, here is the prompt that we use for our quiz builder + tool: + - - {apps.map((app) => { - if (app.name !== "Lesson planner") { - return ( - + {apps.map((app) => { + if (app.name !== "Lesson planner") { + return ( + + - - App: {app.name} - - - {app.prompts.map((prompt) => { - const promptRef = React.createRef(); - itemRefs[slugify(prompt.name)] = promptRef; + App: {app.name} + + + {app.prompts.map((prompt) => { + const promptRef = React.createRef(); + itemRefs[slugify(prompt.name)] = promptRef; - return ( - + - - {prompt.name} - - - Prompt: -
-
-
-                              {prompt.template}
-                            
-
- ); - })} -
-
- ); - } - })} -
-
-
- + {prompt.name} +
+ + Prompt: +
+
+
+                            {prompt.template}
+                          
+
+ ); + })} +
+ + ); + } + })} + +
+
); }; diff --git a/apps/nextjs/src/app/quiz-designer/preview/[slug]/page.tsx b/apps/nextjs/src/app/quiz-designer/preview/[slug]/page.tsx index c8d9b3d05..9f9964907 100644 --- a/apps/nextjs/src/app/quiz-designer/preview/[slug]/page.tsx +++ b/apps/nextjs/src/app/quiz-designer/preview/[slug]/page.tsx @@ -14,8 +14,6 @@ async function getData(slug: string) { notFound: true, }; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - // const { updatedAt, createdAt, ...rest } = sharedData; const questions = sharedData.content; diff --git a/apps/nextjs/src/components/AppComponents/Chat/Chat/ChatModerationDisplay.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/Chat/ChatModerationDisplay.stories.tsx index 84aa6955e..93fb1b5b8 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/Chat/ChatModerationDisplay.stories.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/Chat/ChatModerationDisplay.stories.tsx @@ -3,8 +3,8 @@ import type { Meta, StoryObj } from "@storybook/react"; import { ChatModerationDisplay } from "./ChatModerationDisplay"; -const meta: Meta = { - title: "Components/Chat/ChatModerationDisplay", +const meta = { + title: "Components/Dialogs/ChatModerationDisplay", component: ChatModerationDisplay, tags: ["autodocs"], decorators: [ @@ -14,10 +14,10 @@ const meta: Meta = { ), ], -}; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; const toxicModeration: PersistedModerationBase = { id: "mock-moderation-id", diff --git a/apps/nextjs/src/components/AppComponents/Chat/Chat/chat.tsx b/apps/nextjs/src/components/AppComponents/Chat/Chat/chat.tsx index 0626cf20a..0d5394fac 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/Chat/chat.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/Chat/chat.tsx @@ -15,7 +15,7 @@ export interface ChatProps extends React.ComponentProps<"div"> { export function Chat({ className }: Readonly) { const chatContext = useLessonChat(); const { id, lessonPlan, messages, chat } = chatContext; - const isShared = chat?.isShared || false; + const isShared = chat?.isShared ?? false; return ( diff --git a/apps/nextjs/src/components/AppComponents/Chat/Chat/hooks/useProgressForDownloads.ts b/apps/nextjs/src/components/AppComponents/Chat/Chat/hooks/useProgressForDownloads.ts index a3503b2c0..4f3786afd 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/Chat/hooks/useProgressForDownloads.ts +++ b/apps/nextjs/src/components/AppComponents/Chat/Chat/hooks/useProgressForDownloads.ts @@ -1,9 +1,26 @@ import { useMemo } from "react"; -import type { LooseLessonPlan } from "@oakai/aila/src/protocol/schema"; +import type { + LessonPlanKeys, + LooseLessonPlan, +} from "@oakai/aila/src/protocol/schema"; import { lessonPlanSectionsSchema } from "@oakai/exports/src/schema/input.schema"; import type { ZodIssue } from "zod"; +export type ProgressForDownloads = { + sections: ProgressSection[]; + totalSections: number; + totalSectionsComplete: number; +}; + +export type ProgressSection = { + label: string; + key: LessonPlanKeys; + complete: boolean; +}; + +export type ProgressSections = ProgressSection[]; + /** * For a given list of Zod issues and lessonPlan fields, checks that none of * the errors pertain to the fields. @@ -16,16 +33,6 @@ function getCompleteness(errors: ZodIssue[], fields: string[]) { return !hasErrorInSomeField; } -export type ProgressSections = { - label: string; - key: string; - complete: boolean; -}[]; -type ProgressForDownloads = { - sections: ProgressSections; - totalSections: number; - totalSectionsComplete: number; -}; export function useProgressForDownloads({ lessonPlan, @@ -59,8 +66,9 @@ export function useProgressForDownloads({ */ return true; } - }) || []; - const sections = [ + }) ?? []; + + const sections: ProgressSection[] = [ { label: "Lesson details", key: "title", diff --git a/apps/nextjs/src/components/AppComponents/Chat/button-scroll-to-bottom.tsx b/apps/nextjs/src/components/AppComponents/Chat/button-scroll-to-bottom.tsx deleted file mode 100644 index 5c9800993..000000000 --- a/apps/nextjs/src/components/AppComponents/Chat/button-scroll-to-bottom.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import { - Button, - type ButtonProps, -} from "@/components/AppComponents/Chat/ui/button"; -import { IconArrowDown } from "@/components/AppComponents/Chat/ui/icons"; -import useAnalytics from "@/lib/analytics/useAnalytics"; -import { useAtBottom } from "@/lib/hooks/use-at-bottom"; -import { cn } from "@/lib/utils"; - -export function ButtonScrollToBottom({ className, ...props }: ButtonProps) { - const isAtBottom = useAtBottom(); - const { trackEvent } = useAnalytics(); - return ( - - ); -} diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-history.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-history.stories.tsx index d5ccab452..58ae81767 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-history.stories.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-history.stories.tsx @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import { ChatHistory } from "./chat-history"; -const meta: Meta = { +const meta = { title: "Components/Sidebar/ChatHistory", component: ChatHistory, parameters: { @@ -17,10 +17,10 @@ const meta: Meta = { ), ], -}; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { args: {}, diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-history.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-history.tsx index 6944600c8..f1bf0d168 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-history.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-history.tsx @@ -80,7 +80,7 @@ export function ChatHistory() { - + {" "} AI experiments page - + {" "} Help diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-left-hand-side.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-left-hand-side.tsx index ba3fab32e..6a4e25561 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-left-hand-side.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-left-hand-side.tsx @@ -26,7 +26,7 @@ const ChatLeftHandSide = ({ demo, isDemoUser, }: Readonly) => { - const { messages, chatAreaRef } = useLessonChat(); + const { chatAreaRef } = useLessonChat(); return (
@@ -48,14 +49,9 @@ const ChatLeftHandSide = ({ demo={demo} /> - {!isDemoLocked && ( - - )} + {!isDemoLocked && }
- +
); diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-lessonPlanDisplay.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-lessonPlanDisplay.stories.tsx index 6400caf3a..7cff21bc8 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-lessonPlanDisplay.stories.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-lessonPlanDisplay.stories.tsx @@ -1,53 +1,47 @@ import type { Meta, StoryObj } from "@storybook/react"; import type { ChatContextProps } from "@/components/ContextProviders/ChatProvider"; -import { ChatContext } from "@/components/ContextProviders/ChatProvider"; +import { ChatDecorator } from "@/storybook/decorators/ChatDecorator"; import LessonPlanDisplay from "./chat-lessonPlanDisplay"; -const ChatDecorator: Story["decorators"] = (Story, { parameters }) => ( - - - -); +const chatContext: Partial = { + id: "123", + lastModeration: null, + messages: [], + lessonPlan: { + title: "About Frogs", + keyStage: "Key Stage 2", + subject: "Science", + topic: "Amphibians", + basedOn: { title: "Frogs in Modern Britain" }, + learningOutcome: + "To understand the importance of frogs in British society and culture", + }, + ailaStreamingStatus: "Idle", +}; -const meta: Meta = { +const meta = { title: "Components/LessonPlan/LessonPlanDisplay", component: LessonPlanDisplay, tags: ["autodocs"], decorators: [ChatDecorator], args: { documentContainerRef: { current: null }, + chatEndRef: undefined, + sectionRefs: {}, + showLessonMobile: false, }, -}; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { args: {}, parameters: { - chatContext: {}, + chatContext, }, }; @@ -55,6 +49,7 @@ export const Loading: Story = { args: {}, parameters: { chatContext: { + ...chatContext, lessonPlan: {}, }, }, @@ -64,7 +59,9 @@ export const WithModeration: Story = { args: {}, parameters: { chatContext: { + ...chatContext, lastModeration: { + id: "123", categories: ["l/strong-language"], }, }, diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-lessonPlanDisplay.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-lessonPlanDisplay.tsx index 064fa82b1..4119fdd70 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-lessonPlanDisplay.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-lessonPlanDisplay.tsx @@ -12,9 +12,8 @@ import Skeleton from "../common/Skeleton"; import DropDownSection from "./drop-down-section"; import { GuidanceRequired } from "./guidance-required"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function notEmpty(value: any) { - return ![null, undefined, ""].includes(value); +export function notEmpty(value: unknown) { + return value !== null && value !== undefined && value !== ""; } function basedOnTitle(basedOn: string | BasedOnOptional) { @@ -25,7 +24,7 @@ function basedOnTitle(basedOn: string | BasedOnOptional) { } const displayStyles = cva( - "relative flex flex-col space-y-10 px-14 pb-28 opacity-100 sm:px-24 ", + "relative flex flex-col space-y-10 px-14 pb-28 opacity-100 sm:px-24", ); export type LessonPlanDisplayProps = Readonly<{ @@ -46,17 +45,17 @@ export const LessonPlanDisplay = ({ const lessonPlan = { ...chat.lessonPlan, starterQuiz: - chat.lessonPlan._experimental_starterQuizMathsV0 || + chat.lessonPlan._experimental_starterQuizMathsV0 ?? chat.lessonPlan.starterQuiz, exitQuiz: - chat.lessonPlan._experimental_exitQuizMathsV0 || chat.lessonPlan.exitQuiz, + chat.lessonPlan._experimental_exitQuizMathsV0 ?? chat.lessonPlan.exitQuiz, }; const [userHasCancelledAutoScroll, setUserHasCancelledAutoScroll] = useState(false); useEffect(() => { - const handleUserScroll = () => { + const handleUserScroll = (event: WheelEvent) => { // Check for mousewheel or touch pad scroll event event?.type === "wheel" && setUserHasCancelledAutoScroll(true); }; @@ -144,7 +143,7 @@ export const LessonPlanDisplay = ({ return ( ; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const NonProdStreamingStatus: Story = { + args: { + showStreamingStatus: true, + }, + parameters: { + chatContext: { + ailaStreamingStatus: "StreamingLessonPlan", + }, + }, +}; + +export const DemoBannerPadding: Story = { + args: { + isDemoUser: true, + }, +}; diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-lhs-header.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-lhs-header.tsx index 45fadb88e..06176163f 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-lhs-header.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-lhs-header.tsx @@ -11,19 +11,21 @@ type ChatLhsHeaderProps = { setShowLessonMobile: (value: boolean) => void; showLessonMobile: boolean; isDemoUser: boolean; + showStreamingStatus: boolean; }; const ChatLhsHeader = ({ setShowLessonMobile, showLessonMobile, isDemoUser, + showStreamingStatus, }: Readonly) => { const router = useRouter(); const chat = useLessonChat(); return ( <>
- {process.env.NEXT_PUBLIC_ENVIRONMENT !== "prd" && ( + {showStreamingStatus && (
{chat.ailaStreamingStatus} diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-list/demo-limit-message.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-list/demo-limit-message.stories.tsx new file mode 100644 index 000000000..626d41a72 --- /dev/null +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-list/demo-limit-message.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { ChatModerationProvider } from "@/components/ContextProviders/ChatModerationContext"; + +import { DemoLimitMessage } from "./demo-limit-message"; + +const meta = { + title: "Components/Chat/DemoLimitMessage", + component: DemoLimitMessage, + tags: ["autodocs"], + decorators: [ + (Story) => ( + + + + ), + ], + args: { + id: "test-chat-id", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-list/demo-limit-message.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-list/demo-limit-message.tsx new file mode 100644 index 000000000..1bd5393b1 --- /dev/null +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-list/demo-limit-message.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { ChatMessage } from "@/components/AppComponents/Chat/chat-message"; + +export function DemoLimitMessage({ id }: Readonly<{ id: string }>) { + return ( +
+ } + /> +
+ ); +} diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-list/in-chat-download-buttons.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-list/in-chat-download-buttons.stories.tsx new file mode 100644 index 000000000..0a3a91d47 --- /dev/null +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-list/in-chat-download-buttons.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { + DemoDecorator, + demoParams, +} from "@/storybook/decorators/DemoDecorator"; + +import { InChatDownloadButtons } from "./in-chat-download-buttons"; + +const meta = { + title: "Components/Chat/InChatDownloadButtons", + component: InChatDownloadButtons, + tags: ["autodocs"], + args: { + id: "test-chat-id", + }, + decorators: [DemoDecorator], + parameters: { + ...demoParams({ isDemoUser: true }), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const SharingDisabled: Story = { + parameters: { + ...demoParams({ isDemoUser: true, isSharingEnabled: false }), + }, +}; diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-list/in-chat-download-buttons.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-list/in-chat-download-buttons.tsx new file mode 100644 index 000000000..834baf05b --- /dev/null +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-list/in-chat-download-buttons.tsx @@ -0,0 +1,63 @@ +import { OakBox, OakFlex, OakIcon, OakSpan } from "@oaknational/oak-components"; +import Link from "next/link"; + +import { useDemoUser } from "@/components/ContextProviders/Demo"; + +import { useDialog } from "../../DialogContext"; + +export const InChatDownloadButtons = ({ id }: { readonly id: string }) => { + const demo = useDemoUser(); + const { setDialogWindow } = useDialog(); + + return ( + + {demo.isSharingEnabled && ( + { + if (!demo.isSharingEnabled) { + setDialogWindow("demo-share-locked"); + } + }} + > + Download + + )} + + + ); +}; + +const InnerInChatButton = ({ + iconName, + children, +}: { + readonly iconName: "download" | "share"; + readonly children: string; +}) => { + return ( + + + + + {children} + + ); +}; diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-list.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-list/index.tsx similarity index 70% rename from apps/nextjs/src/components/AppComponents/Chat/chat-list.tsx rename to apps/nextjs/src/components/AppComponents/Chat/chat-list/index.tsx index 32b00ee94..49446abc2 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-list.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-list/index.tsx @@ -1,47 +1,24 @@ "use client"; -import type { Dispatch, SetStateAction } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import type { PersistedModerationBase } from "@oakai/core/src/utils/ailaModeration/moderationSchema"; -import { OakBox, OakFlex, OakIcon, OakSpan } from "@oaknational/oak-components"; import type { Message } from "ai"; -import Link from "next/link"; import { ChatMessage } from "@/components/AppComponents/Chat/chat-message"; import { useLessonChat } from "@/components/ContextProviders/ChatProvider"; import type { DemoContextProps } from "@/components/ContextProviders/Demo"; -import { useDialog } from "../DialogContext"; -import type { AilaStreamingStatus } from "./Chat/hooks/useAilaStreamingStatus"; -import { useProgressForDownloads } from "./Chat/hooks/useProgressForDownloads"; -import type { DialogTypes } from "./Chat/types"; +import type { AilaStreamingStatus } from "../Chat/hooks/useAilaStreamingStatus"; +import { useProgressForDownloads } from "../Chat/hooks/useProgressForDownloads"; +import { DemoLimitMessage } from "./demo-limit-message"; +import { InChatDownloadButtons } from "./in-chat-download-buttons"; export interface ChatListProps { isDemoLocked: boolean; showLessonMobile: boolean; demo: DemoContextProps; } - -function DemoLimitMessage({ id }: Readonly<{ id: string }>) { - return ( -
- } - /> -
- ); -} - export function ChatList({ isDemoLocked, showLessonMobile, @@ -129,7 +106,6 @@ export const ChatMessagesDisplay = ({ demo, }: ChatMessagesDisplayProps) => { const { lessonPlan, isStreaming } = useLessonChat(); - const { setDialogWindow } = useDialog(); const { totalSections, totalSectionsComplete } = useProgressForDownloads({ lessonPlan, isStreaming, @@ -228,71 +204,7 @@ export const ChatMessagesDisplay = ({ (message.role !== "user" && message.content.includes("download") && message.content.includes("share")), - ) && } + ) && } ); }; - -const InChatDownloadButtons = ({ - demo, - id, - setDialogWindow, -}: { - readonly demo: DemoContextProps; - readonly id: string; - readonly setDialogWindow: Dispatch>; -}) => { - return ( - - {demo.isSharingEnabled && ( - { - if (!demo.isSharingEnabled) { - setDialogWindow("demo-share-locked"); - } - }} - > - Download - - )} - - - ); -}; - -const InnerInChatButton = ({ - iconName, - - children, -}: { - readonly iconName: "download" | "share"; - - readonly children: string; -}) => { - return ( - - - - - {children} - - ); -}; diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-message/ChatMessagePart.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-message/ChatMessagePart.stories.tsx new file mode 100644 index 000000000..a0bfcc600 --- /dev/null +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-message/ChatMessagePart.stories.tsx @@ -0,0 +1,74 @@ +import type { MessagePart } from "@oakai/aila/src/protocol/jsonPatchProtocol"; +import type { Meta, StoryObj } from "@storybook/react"; + +import { ChatMessagePart } from "./ChatMessagePart"; + +const meta = { + title: "Components/Chat/ChatMessagePart", + component: ChatMessagePart, + tags: ["autodocs"], + args: { + inspect: false, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const basePart: Omit = { + type: "message-part", + id: "test-part-id", + isPartial: false, +}; + +export const PromptMessagePart: Story = { + args: { + part: { + ...basePart, + document: { + type: "prompt", + message: + "Are the learning outcome and learning cycles appropriate for your pupils? If not, suggest an edit. Otherwise, tap **Continue** to move on to the next step.", + }, + }, + }, +}; + +export const ErrorMessagePart: Story = { + args: { + part: { + ...basePart, + document: { + type: "error", + message: + "**Unfortunately you’ve exceeded your fair usage limit for today.** Please come back in 11 hours. If you require a higher limit, please [make a request](https://forms.gle/tHsYMZJR367zydsG8).", + }, + }, + }, +}; + +export const TextMessagePart: Story = { + args: { + part: { + ...basePart, + document: { + type: "text", + value: + "Are the learning outcome and learning cycles appropriate for your pupils? If not, suggest an edit. Otherwise, tap **Continue** to move on to the next step.", + }, + }, + }, +}; + +export const WithInspector: Story = { + args: { + inspect: true, + part: { + ...basePart, + document: { + type: "prompt", + message: "This is a prompt", + }, + }, + }, +}; diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-message/ChatMessagePart.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-message/ChatMessagePart.tsx new file mode 100644 index 000000000..1876d9e4b --- /dev/null +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-message/ChatMessagePart.tsx @@ -0,0 +1,88 @@ +import type { + ErrorDocument, + MessagePart, + PromptDocument, + TextDocument, +} from "@oakai/aila/src/protocol/jsonPatchProtocol"; +import { aiLogger } from "@oakai/logger"; + +import { MemoizedReactMarkdownWithStyles } from "@/components/AppComponents/Chat/markdown"; + +const log = aiLogger("chat"); + +const components = { + comment: NonRenderedPart, + prompt: PromptMessagePart, + error: ErrorMessagePart, + bad: NonRenderedPart, + patch: NonRenderedPart, + /** + * Patches do not get rendered, they get applied to the lesson plan + * state, which is then rendered in the right hand side. + */ + experimentalPatch: NonRenderedPart, + state: NonRenderedPart, + text: TextMessagePart, + action: NonRenderedPart, + moderation: NonRenderedPart, + id: NonRenderedPart, + unknown: NonRenderedPart, +}; + +export interface ChatMessagePartProps { + part: MessagePart; + inspect: boolean; +} + +export function ChatMessagePart({ + part, + inspect, +}: Readonly) { + const PartComponent = components[part.document.type] as React.ComponentType<{ + part: typeof part.document; + }>; + + if (!PartComponent) { + log.info("Unknown part type", part.document.type, JSON.stringify(part)); + return null; + } + + return ( +
+ + + {inspect && } +
+ ); +} + +function NonRenderedPart() { + return null; +} + +function PromptMessagePart({ part }: Readonly<{ part: PromptDocument }>) { + return ; +} + +function ErrorMessagePart({ + part, +}: Readonly<{ + part: ErrorDocument; +}>) { + const markdown = part.message ?? "Sorry, an error has occurred"; + return ; +} + +function TextMessagePart({ part }: Readonly<{ part: TextDocument }>) { + return ; +} + +function PartInspector({ part }: Readonly<{ part: MessagePart }>) { + return ( +
+
+        {JSON.stringify(part, null, 2)}
+      
+
+ ); +} diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.stories.tsx new file mode 100644 index 000000000..e2901608f --- /dev/null +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { ChatModerationProvider } from "@/components/ContextProviders/ChatModerationContext"; + +import { ChatMessage } from "./"; + +const meta = { + title: "Components/Chat/ChatMessage", + component: ChatMessage, + tags: ["autodocs"], + decorators: [ + (Story) => ( + + + + ), + ], + args: { + chatId: "test-chat-id", + persistedModerations: [], + ailaStreamingStatus: "Idle", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const UserMessage: Story = { + args: { + message: { + id: "test-chat-id", + content: + "Create a lesson plan about the end of Roman Britain for key stage 3 history", + role: "user", + }, + }, +}; + +export const LlmMessage: Story = { + args: { + message: { + id: "test-chat-id", + content: + '{"type":"llmMessage","sectionsToEdit":["learningOutcome","learningCycles"],"patches":[{"type":"patch","reasoning":"Since there are no existing Oak lessons for this topic, I have created a new lesson plan from scratch focusing on the end of Roman Britain.","value":{"type":"string","op":"add","path":"/learningOutcome","value":"I can explain the reasons behind the decline of Roman Britain and its impact on society."},"status":"complete"},{"type":"patch","reasoning":"I have outlined the learning cycles to break down the lesson structure for teaching about the end of Roman Britain.","value":{"type":"string-array","op":"add","path":"/learningCycles","value":["Identify the key events leading to the end of Roman Britain.","Describe the societal changes that occurred post-Roman withdrawal.","Analyse the archaeological evidence of Roman Britain\'s legacy."]},"status":"complete"}],"sectionsEdited":["learningOutcome","learningCycles"],"prompt":{"type":"text","value":"Are the learning outcome and learning cycles appropriate for your pupils? If not, suggest an edit. Otherwise, tap **Continue** to move on to the next step."},"status":"complete"}', + role: "assistant", + }, + }, +}; + +export const ErrorMessage: Story = { + args: { + message: { + id: "test-chat-id", + role: "assistant", + content: + '{"type":"error","value":"Rate limit exceeded","message":"**Unfortunately you’ve exceeded your fair usage limit for today.** Please come back in 11 hours. If you require a higher limit, please [make a request](https://forms.gle/tHsYMZJR367zydsG8)."}', + }, + }, +}; diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.tsx index b29b77a29..9ad0c1644 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.tsx @@ -1,26 +1,12 @@ // Inspired by Chatbot-UI and modified to fit the needs of this project // @see https://github.com/mckaywrigley/chatbot-ui/blob/main/components/Chat/ChatMessage.tsx -import type { ReactNode } from "react"; +import type { ReactNode, JSX } from "react"; import { useState } from "react"; -import type { - ActionDocument, - BadDocument, - CommentDocument, - ErrorDocument, - ExperimentalPatchDocument, - MessagePart, - ModerationDocument, - PatchDocument, - PromptDocument, - StateDocument, - TextDocument, - UnknownDocument, -} from "@oakai/aila/src/protocol/jsonPatchProtocol"; +import type { MessagePart } from "@oakai/aila/src/protocol/jsonPatchProtocol"; import { parseMessageParts } from "@oakai/aila/src/protocol/jsonPatchProtocol"; import { isSafe } from "@oakai/core/src/utils/ailaModeration/helpers"; import type { PersistedModerationBase } from "@oakai/core/src/utils/ailaModeration/moderationSchema"; -import { aiLogger } from "@oakai/logger"; import type { Message } from "ai"; import { MemoizedReactMarkdownWithStyles } from "@/components/AppComponents/Chat/markdown"; @@ -28,12 +14,10 @@ import { useChatModeration } from "@/components/ContextProviders/ChatModerationC import { Icon } from "@/components/Icon"; import { cn } from "@/lib/utils"; -import type { ModerationModalHelpers } from "../../FeedbackForms/ModerationFeedbackModal"; import type { AilaStreamingStatus } from "../Chat/hooks/useAilaStreamingStatus"; +import { ChatMessagePart } from "./ChatMessagePart"; import { isModeration } from "./protocol"; -const log = aiLogger("chat"); - export interface ChatMessageProps { chatId: string; // Needed for when we refactor to use a moderation provider message: Message; @@ -136,22 +120,18 @@ export function ChatMessage({ errorType={hasError ? "generic" : null} type={getAvatarType()} > -
{ setInspect(!inspect); }} /> {message.id !== "working-on-it-initial" && - messageParts.map((part, index) => { + messageParts.map((part) => { return ( -
- +
+
); })} @@ -216,135 +196,8 @@ function MessageWrapper({ function MessageTextWrapper({ children }: Readonly<{ children: ReactNode }>) { return ( -
+
{children}
); } - -export interface ChatMessagePartProps { - part: MessagePart; - inspect: boolean; - moderationModalHelpers: ModerationModalHelpers; -} - -function ChatMessagePart({ - part, - inspect, - moderationModalHelpers, -}: Readonly) { - const PartComponent = { - comment: CommentMessagePart, - prompt: PromptMessagePart, - error: ErrorMessagePart, - bad: BadMessagePart, - patch: PatchMessagePart, - experimentalPatch: ExperimentalPatchMessageComponent, - state: StateMessagePart, - text: TextMessagePart, - action: ActionMessagePart, - moderation: ModerationMessagePart, - id: IdMessagePart, - unknown: UnknownMessagePart, - }[part.document.type] as React.ComponentType<{ - part: typeof part.document; - moderationModalHelpers: ModerationModalHelpers; - }>; - - if (!PartComponent) { - log.info("Unknown part type", part.document.type, JSON.stringify(part)); - return null; - } - - return ( -
- - - { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - inspect && - } -
- ); -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function BadMessagePart({ part }: Readonly<{ part: BadDocument }>) { - return null; -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function CommentMessagePart({ part }: Readonly<{ part: CommentDocument }>) { - return null; -} - -function PromptMessagePart({ part }: Readonly<{ part: PromptDocument }>) { - return ; -} - -function ModerationMessagePart({ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - part, -}: Readonly<{ part: ModerationDocument }>) { - return null; -} - -function ErrorMessagePart({ - part, -}: Readonly<{ - part: ErrorDocument; -}>) { - const markdown = part.message || "Sorry, an error has occurred"; - return ; -} - -function TextMessagePart({ part }: Readonly<{ part: TextDocument }>) { - return ; -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function PatchMessagePart({ part }: Readonly<{ part: PatchDocument }>) { - return null; -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function StateMessagePart({ part }: Readonly<{ part: StateDocument }>) { - return null; -} - -function IdMessagePart() { - return null; -} - -function ActionMessagePart({ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - part, -}: Readonly<{ part: ActionDocument }>) { - return null; -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function UnknownMessagePart({ part }: Readonly<{ part: UnknownDocument }>) { - return null; -} - -function PartInspector({ part }: Readonly<{ part: MessagePart }>) { - return ( -
-
-        {JSON.stringify(part, null, 2)}
-      
-
- ); -} - -/** - * Patches do not get rendered, they get applied to the lesson plan - * state, which is then rendered in the right hand side. - */ -function ExperimentalPatchMessageComponent() { - return null; -} diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-panel-disclaimer.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-panel-disclaimer.tsx index 4d291ae7a..b0f18e05d 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-panel-disclaimer.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-panel-disclaimer.tsx @@ -1,4 +1,8 @@ -const ChatPanelDisclaimer = ({ size }: { readonly size: "sm" | "md" | "lg" }) => { +const ChatPanelDisclaimer = ({ + size, +}: { + readonly size: "sm" | "md" | "lg"; +}) => { return (

Aila can make mistakes. Check your lesson before use. See our{" "} @@ -9,7 +13,7 @@ const ChatPanelDisclaimer = ({ size }: { readonly size: "sm" | "md" | "lg" }) => > terms and conditions - . + {"."}

); }; diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-panel.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-panel.stories.tsx new file mode 100644 index 000000000..384b1f794 --- /dev/null +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-panel.stories.tsx @@ -0,0 +1,141 @@ +import type { Message } from "@oakai/aila/src/core/chat"; +import type { Meta, StoryObj } from "@storybook/react"; + +import { ChatDecorator } from "@/storybook/decorators/ChatDecorator"; +import { LessonPlanTrackingDecorator } from "@/storybook/decorators/LessonPlanTrackingDecorator"; +import { SidebarDecorator } from "@/storybook/decorators/SidebarDecorator"; + +import { ChatPanel } from "./chat-panel"; + +const DummyMessage: Message = { + content: "Dummy message", + id: "123", + role: "user", +}; + +const meta = { + title: "Components/Chat/ChatPanel", + component: ChatPanel, + tags: ["autodocs"], + decorators: [ChatDecorator, LessonPlanTrackingDecorator, SidebarDecorator], + args: { + isDemoLocked: false, + }, + parameters: { + chatContext: { + messages: [DummyMessage], + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const NoMessages: Story = { + args: {}, + parameters: { + chatContext: { + messages: [], + }, + }, +}; + +export const DemoLocked: Story = { + args: { + isDemoLocked: true, + }, +}; + +export const Idle: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "Idle", + }, + }, +}; + +export const IdleWithQueuedUserAction: Story = { + args: {}, + parameters: { + chatContext: { + queuedUserAction: "regenerate", + ailaStreamingStatus: "Idle", + }, + }, +}; + +export const Loading: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "Loading", + }, + }, +}; + +export const RequestMade: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "RequestMade", + }, + }, +}; + +export const StreamingLessonPlan: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "StreamingLessonPlan", + }, + }, +}; + +export const StreamingChatResponse: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "StreamingChatResponse", + }, + }, +}; + +export const StreamingWithQueuedUserAction: Story = { + args: {}, + parameters: { + chatContext: { + queuedUserAction: "regenerate", + ailaStreamingStatus: "StreamingLessonPlan", + }, + }, +}; + +export const Moderating: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "Moderating", + }, + }, +}; + +export const ModeratingWithRegenerateUserAction: Story = { + args: {}, + parameters: { + chatContext: { + queuedUserAction: "regenerate", + ailaStreamingStatus: "Moderating", + }, + }, +}; + +export const CustomQueuedUserAction: Story = { + args: {}, + parameters: { + chatContext: { + queuedUserAction: "Increase the reading age of that section", + ailaStreamingStatus: "Moderating", + }, + }, +}; diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-panel.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-panel.tsx index fca32ed91..b2b58dcb2 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-panel.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-panel.tsx @@ -1,6 +1,5 @@ import { cva } from "class-variance-authority"; -import { ButtonScrollToBottom } from "@/components/AppComponents/Chat/button-scroll-to-bottom"; import { PromptForm } from "@/components/AppComponents/Chat/prompt-form"; import { useLessonChat } from "@/components/ContextProviders/ChatProvider"; import useAnalytics from "@/lib/analytics/useAnalytics"; @@ -8,7 +7,6 @@ import useAnalytics from "@/lib/analytics/useAnalytics"; import ChatPanelDisclaimer from "./chat-panel-disclaimer"; interface ChatPanelProps { - isEmptyScreen: boolean; isDemoLocked: boolean; } @@ -18,13 +16,11 @@ function LockedPromptForm() { ); } -export function ChatPanel({ - isEmptyScreen, - isDemoLocked, -}: Readonly) { +export function ChatPanel({ isDemoLocked }: Readonly) { const chat = useLessonChat(); const { id, + messages, isLoading, input, setInput, @@ -34,12 +30,13 @@ export function ChatPanel({ queuedUserAction, } = chat; + const hasMessages = !!messages.length; + const { trackEvent } = useAnalytics(); - const containerClass = `grid w-full grid-cols-1 ${isEmptyScreen ? "sm:grid-cols-1" : ""} peer-[[data-state=open]]:group-[]:lg:pl-[250px] peer-[[data-state=open]]:group-[]:xl:pl-[300px]`; + const containerClass = `grid w-full grid-cols-1 ${hasMessages ? "sm:grid-cols-1" : ""} peer-[[data-state=open]]:group-[]:lg:pl-[250px] peer-[[data-state=open]]:group-[]:xl:pl-[300px]`; return (
- -
+
{!isDemoLocked && ( { @@ -57,7 +54,7 @@ export function ChatPanel({ input={input} setInput={setInput} ailaStreamingStatus={ailaStreamingStatus} - isEmptyScreen={isEmptyScreen} + hasMessages={hasMessages} queueUserAction={queueUserAction} queuedUserAction={queuedUserAction} /> @@ -73,7 +70,7 @@ export function ChatPanel({ const chatBoxWrap = cva(["mx-auto w-full "], { variants: { - isEmptyScreen: { + hasMessages: { false: "max-w-2xl ", true: "", }, diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-quick-buttons.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-quick-buttons.stories.tsx new file mode 100644 index 000000000..05adefe11 --- /dev/null +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-quick-buttons.stories.tsx @@ -0,0 +1,112 @@ +import type { Message } from "@oakai/aila/src/core/chat"; +import type { Meta, StoryObj } from "@storybook/react"; + +import { ChatDecorator } from "@/storybook/decorators/ChatDecorator"; +import { LessonPlanTrackingDecorator } from "@/storybook/decorators/LessonPlanTrackingDecorator"; + +import ChatQuickButtons from "./chat-quick-buttons"; + +const DummyMessage: Message = { + content: "Dummy message", + id: "123", + role: "user", +}; + +const meta = { + title: "Components/Chat/ChatQuickButtons", + component: ChatQuickButtons, + tags: ["autodocs"], + decorators: [ChatDecorator, LessonPlanTrackingDecorator], + parameters: { + chatContext: { + messages: [DummyMessage], + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Idle: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "Idle", + }, + }, +}; + +export const Loading: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "Loading", + }, + }, +}; + +export const LoadingWithoutMessages: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "Loading", + messages: [], + }, + }, +}; + +export const RequestMade: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "RequestMade", + }, + }, +}; + +export const StreamingLessonPlan: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "StreamingLessonPlan", + }, + }, +}; + +export const StreamingChatResponse: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "StreamingChatResponse", + }, + }, +}; + +export const Moderating: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "Moderating", + }, + }, +}; + +export const StreamingWithQueuedUserAction: Story = { + args: {}, + parameters: { + chatContext: { + queuedUserAction: "regenerate", + ailaStreamingStatus: "StreamingLessonPlan", + }, + }, +}; + +export const ModeratingWithQueuedUserAction: Story = { + args: {}, + parameters: { + chatContext: { + queuedUserAction: "regenerate", + ailaStreamingStatus: "Moderating", + }, + }, +}; diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-quick-buttons.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-quick-buttons.tsx index 4a97e507f..de37e5a74 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-quick-buttons.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-quick-buttons.tsx @@ -12,16 +12,12 @@ import type { AilaStreamingStatus } from "./Chat/hooks/useAilaStreamingStatus"; import ChatButton from "./ui/chat-button"; import { IconRefresh, IconStop } from "./ui/icons"; -export type QuickActionButtonsProps = Readonly<{ - isEmptyScreen: boolean; -}>; - const shouldAllowStop = ( ailaStreamingStatus: AilaStreamingStatus, - isEmptyScreen: boolean, + hasMessages: boolean, queuedUserAction: string | null, ) => { - if (!isEmptyScreen) { + if (!hasMessages) { return false; } @@ -43,7 +39,7 @@ const shouldAllowStop = ( return false; }; -const QuickActionButtons = ({ isEmptyScreen }: QuickActionButtonsProps) => { +const QuickActionButtons = () => { const chat = useLessonChat(); const { trackEvent } = useAnalytics(); const lessonPlanTracking = useLessonPlanTracking(); @@ -57,13 +53,15 @@ const QuickActionButtons = ({ isEmptyScreen }: QuickActionButtonsProps) => { queuedUserAction, } = chat; + const hasMessages = !!messages.length; + const shouldAllowUserAction = ["Idle", "Moderating"].includes(ailaStreamingStatus) && !queuedUserAction; const handleRegenerate = useCallback(() => { trackEvent("chat:regenerate", { id: id }); const lastUserMessage = - findLast(messages, (m) => m.role === "user")?.content || ""; + findLast(messages, (m) => m.role === "user")?.content ?? ""; lessonPlanTracking.onClickRetry(lastUserMessage); queueUserAction("regenerate"); }, [queueUserAction, lessonPlanTracking, messages, trackEvent, id]); @@ -106,7 +104,7 @@ const QuickActionButtons = ({ isEmptyScreen }: QuickActionButtonsProps) => { {shouldAllowStop( ailaStreamingStatus, - isEmptyScreen, + hasMessages, queuedUserAction, ) && ( > + > = {}; const scrollToBottom = () => { if (chatEndRef.current) { @@ -57,7 +61,7 @@ const ChatRightHandSideLesson = ({ return (
1 && showLessonMobile ? "flex" : "hidden"} fixed bottom-20 left-0 right-0 items-center justify-center duration-150 sm:hidden`} + className={`${messages.length > 1 && showLessonMobile ? "flex" : "hidden"} fixed bottom-20 left-0 right-0 items-center justify-center duration-150 sm:hidden`} > { - const slidesLessonSections = lessonSections.filter( - (section) => - section !== "Title" && - section !== "Key stage" && - section !== "Subject" && - section !== "Prior knowledge" && - section !== "Key learning points" && - section !== "Misconceptions" && - section !== "Starter quiz" && - section !== "Exit Quiz", - ); - - const quizLessonSections = lessonSections.filter( - (section) => - section !== "Title" && - section !== "Key stage" && - section !== "Subject" && - section !== "Prior lnowledge" && - section !== "Key learning points" && - section !== "Misconceptions" && - section !== "Learning cycle 1" && - section !== "Keywords" && - section !== "Learning cycles" && - section !== "Learning outcome", - ); - - return ( - - - - 1 lesson plan - - -
- {lessonSections.map((section) => { - if ( - section == "Title" || - section == "Key stage" || - section == "Subject" - ) { - return null; - } - return ( -
- - - {convertTitleCaseToSentenceCase( - handleRewordingSections(section), - )} - -
- ); - })} -
-
-
- - - 1 slide deck - - -
- {slidesLessonSections.map((section) => { - if ( - section == "Title" || - section == "Key stage" || - section == "Subject" - ) { - return null; - } - return ( -
- - - {convertTitleCaseToSentenceCase( - handleRewordingSections(section), - )} - -
- ); - })} -
-
-
- - - 2 quizzes - - -
- {quizLessonSections.map((section) => { - if ( - section == "Title" || - section == "Key stage" || - section == "Subject" - ) { - return null; - } - return ( -
- - - {convertTitleCaseToSentenceCase( - handleRewordingSections(section), - )} - -
- ); - })} -
-
-
- -
- - 1 worksheet - -
-
-
- ); -}; - -// Define prop types for each component -interface AccordionItemProps - extends React.ComponentPropsWithoutRef { - readonly children: React.ReactNode; -} - -interface AccordionTriggerProps - extends React.ComponentPropsWithoutRef { - readonly children: React.ReactNode; -} - -interface AccordionContentProps - extends React.ComponentPropsWithoutRef { - readonly children: React.ReactNode; -} - -const AccordionItem = React.forwardRef( - ({ children, ...props }, forwardedRef) => ( - - {children} - - ), -); -AccordionItem.displayName = "AccordionItem"; - -const AccordionTrigger = React.forwardRef< - HTMLDivElement, - AccordionTriggerProps ->(({ children, ...props }) => ( - - - {children} - - - -)); -AccordionTrigger.displayName = "AccordionTrigger"; - -const AccordionContent = React.forwardRef< - HTMLDivElement, - AccordionContentProps ->(({ children, ...props }, forwardedRef) => ( - -
{children}
-
-)); - -AccordionContent.displayName = "AccordionContent"; - -export default ChatStartAccordion; diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-start-form.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-start-form.stories.tsx index f57506495..22f08d579 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-start-form.stories.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-start-form.stories.tsx @@ -2,14 +2,14 @@ import type { Meta, StoryObj } from "@storybook/react"; import { ChatStartForm } from "./chat-start-form"; -const meta: Meta = { - title: "Components/Chat/ChatStartForm", +const meta = { + title: "Components/Chat Start/ChatStartForm", component: ChatStartForm, tags: ["autodocs"], -}; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { args: { diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-start.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-start.stories.tsx index d02716551..45f1c3ecc 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-start.stories.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-start.stories.tsx @@ -1,16 +1,18 @@ import type { Meta, StoryObj } from "@storybook/react"; import { DemoProvider } from "@/components/ContextProviders/Demo"; +import { chromaticParams } from "@/storybook/chromatic"; import { DialogProvider } from "../DialogContext"; import { ChatStart } from "./chat-start"; -const meta: Meta = { +const meta = { title: "Pages/Chat/Chat Start", component: ChatStart, parameters: { // Including custom decorators changes the layout from fullscreen layout: "fullscreen", + ...chromaticParams(["mobile", "desktop"]), }, decorators: [ (Story) => ( @@ -21,10 +23,10 @@ const meta: Meta = { ), ], -}; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { args: {}, diff --git a/apps/nextjs/src/components/AppComponents/Chat/clear-history.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/clear-history.stories.tsx index 084254fb1..de0d65c04 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/clear-history.stories.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/clear-history.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import { ClearHistory } from "./clear-history"; -const meta: Meta = { +const meta = { title: "Components/Sidebar/ClearHistory", component: ClearHistory, tags: ["autodocs"], @@ -14,10 +14,10 @@ const meta: Meta = { return ; }, ], -}; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { args: { diff --git a/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/action-button-wrapper.tsx b/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/action-button-wrapper.tsx index 3db3b8c30..447d1f527 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/action-button-wrapper.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/action-button-wrapper.tsx @@ -1,6 +1,7 @@ import { useRef, useState } from "react"; import { getLastAssistantMessage } from "@oakai/aila/src/helpers/chat/getLastAssistantMessage"; +import type { LessonPlanSectionWhileStreaming } from "@oakai/aila/src/protocol/schema"; import { OakBox } from "@oaknational/oak-components"; import type { AilaUserModificationAction } from "@prisma/client"; @@ -18,7 +19,7 @@ import type { FeedbackOption } from "./drop-down-form-wrapper"; export type ActionButtonWrapperProps = Readonly<{ sectionTitle: string; sectionPath: string; - sectionValue: Record | string | Array; + sectionValue: LessonPlanSectionWhileStreaming; options: ModifyOptions | AdditionalMaterialOptions; buttonText: string; actionButtonLabel: string; @@ -59,7 +60,7 @@ const ActionButtonWrapper = ({ chatId: id, messageId: lastAssistantMessage.id, sectionPath, - sectionValue, + sectionValue: String(sectionValue), action: selectedRadio.enumValue, actionOtherText: userFeedbackText || null, }; diff --git a/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/add-additional-materials-button.tsx b/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/add-additional-materials-button.tsx index 23a0353b3..8b2e58468 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/add-additional-materials-button.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/add-additional-materials-button.tsx @@ -1,3 +1,4 @@ +import type { LessonPlanSectionWhileStreaming } from "@oakai/aila/src/protocol/schema"; import type { AilaUserModificationAction } from "@prisma/client"; import ActionButtonWrapper from "./action-button-wrapper"; @@ -7,7 +8,7 @@ import type { FeedbackOption } from "./drop-down-form-wrapper"; export type AdditionalMaterialsProps = Readonly<{ sectionTitle: string; sectionPath: string; - sectionValue: Record | string | Array; + sectionValue: LessonPlanSectionWhileStreaming; }>; const AddAdditionalMaterialsButton = ({ diff --git a/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/chat-section.tsx b/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/chat-section.tsx index e999b74c6..7e1d8b207 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/chat-section.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/chat-section.tsx @@ -1,3 +1,7 @@ +import type { + LessonPlanKeys, + LessonPlanSectionWhileStreaming, +} from "@oakai/aila/src/protocol/schema"; import { sectionToMarkdown } from "@oakai/aila/src/protocol/sectionToMarkdown"; import { OakFlex } from "@oaknational/oak-components"; import { lessonSectionTitlesAndMiniDescriptions } from "data/lessonSectionTitlesAndMiniDescriptions"; @@ -9,17 +13,18 @@ import FlagButton from "./flag-button"; import ModifyButton from "./modify-button"; export type ChatSectionProps = Readonly<{ - objectKey: string; - value: Record | string | Array; + section: LessonPlanKeys; + value: LessonPlanSectionWhileStreaming; }>; -const ChatSection = ({ objectKey, value }: ChatSectionProps) => { + +const ChatSection = ({ section, value }: ChatSectionProps) => { return ( { $position="relative" $display={["none", "flex"]} > - {objectKey === "additionalMaterials" && value === "None" ? ( + {section === "additionalMaterials" && value === "None" ? ( ) : ( )} diff --git a/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/flag-button.tsx b/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/flag-button.tsx index 0254d8ef4..2a98e3c3a 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/flag-button.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/flag-button.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { getLastAssistantMessage } from "@oakai/aila/src/helpers/chat/getLastAssistantMessage"; +import type { LessonPlanSectionWhileStreaming } from "@oakai/aila/src/protocol/schema"; import type { AilaUserFlagType } from "@oakai/db"; import { OakBox, OakP, OakRadioGroup } from "@oaknational/oak-components"; import styled from "styled-components"; @@ -26,7 +27,7 @@ type FlagButtonOptions = typeof flagOptions; export type FlagButtonProps = Readonly<{ sectionTitle: string; sectionPath: string; - sectionValue: Record | string | Array; + sectionValue: LessonPlanSectionWhileStreaming; }>; const FlagButton = ({ @@ -48,6 +49,25 @@ const FlagButton = ({ const { mutateAsync } = trpc.chat.chatFeedback.flagSection.useMutation(); + const isPlainObject = (value: unknown): value is Record => { + return typeof value === "object" && value !== null && !Array.isArray(value); + }; + + const prepareSectionValue = ( + value: LessonPlanSectionWhileStreaming, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): string | any[] | Record => { + if ( + typeof value === "string" || + Array.isArray(value) || + isPlainObject(value) + ) { + return value; + } + // For numbers or any other types, convert to string + return String(value); + }; + const flagSectionContent = async () => { if (selectedRadio && lastAssistantMessage) { const payload = { @@ -56,7 +76,7 @@ const FlagButton = ({ flagType: selectedRadio.enumValue, userComment: userFeedbackText, sectionPath, - sectionValue, + sectionValue: prepareSectionValue(sectionValue), }; await mutateAsync(payload); } @@ -93,7 +113,7 @@ const FlagButton = ({ > {flagOptions.map((option) => ( new Promise((resolve) => setTimeout(resolve, ms)); -const ChatDecorator: Story["decorators"] = (Story, { parameters }) => ( - - - -); - -const meta: Meta = { +const meta = { title: "Components/LessonPlan/DropDownSection", component: DropDownSection, tags: ["autodocs"], args: { - objectKey: "learningOutcome", + section: "learningOutcome", value: "I can explain the reasons why frogs are so important to British society and culture", documentContainerRef: { current: null }, streamingTimeout: 0, + userHasCancelledAutoScroll: false, + sectionRefs: {}, + showLessonMobile: false, }, decorators: [ChatDecorator], -}; + parameters: { + chatContext: { + id: "123", + lastModeration: null, + messages: [], + lessonPlan: { + title: "About Frogs", + keyStage: "Key Stage 2", + subject: "Science", + topic: "Amphibians", + basedOn: { id: "testId", title: "Frogs in Modern Britain" }, + learningOutcome: + "To understand the importance of frogs in British society and culture", + }, + ailaStreamingStatus: "Idle", + }, + }, +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { args: {}, @@ -89,7 +83,7 @@ export const Closed: Story = { export const AdditionalMaterials: Story = { args: { - objectKey: "additionalMaterials", + section: "additionalMaterials", value: "None", }, }; @@ -116,7 +110,7 @@ export const ModifyAdditionalMaterials: Story = { }, }, args: { - objectKey: "additionalMaterials", + section: "additionalMaterials", value: "None", }, play: async ({ canvasElement }) => { diff --git a/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/index.tsx b/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/index.tsx index aac7f06e6..15800276d 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/index.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/index.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react"; +import type { LessonPlanKeys } from "@oakai/aila/src/protocol/schema"; import { camelCaseToSentenceCase } from "@oakai/core/src/utils/camelCaseConversion"; import { OakBox, OakFlex, OakP } from "@oaknational/oak-components"; import { equals } from "ramda"; @@ -15,7 +16,7 @@ import ChatSection from "./chat-section"; const HALF_SECOND = 500; export type DropDownSectionProps = Readonly<{ - objectKey: string; + section: LessonPlanKeys; sectionRefs: Record>; // eslint-disable-next-line @typescript-eslint/no-explicit-any value: any; @@ -26,7 +27,7 @@ export type DropDownSectionProps = Readonly<{ }>; const DropDownSection = ({ - objectKey, + section, sectionRefs, value, documentContainerRef, @@ -35,7 +36,7 @@ const DropDownSection = ({ streamingTimeout = HALF_SECOND, }: DropDownSectionProps) => { const sectionRef = useRef(null); - if (sectionRefs) sectionRefs[objectKey] = sectionRef; + if (sectionRefs) sectionRefs[section] = sectionRef; const [isOpen, setIsOpen] = useState(false); const [status, setStatus] = useState<"empty" | "isStreaming" | "isLoaded">( "empty", @@ -56,7 +57,7 @@ const DropDownSection = ({ setStatus("isStreaming"); if (sectionRef && sectionHasFired === false && status === "isStreaming") { - if (objectKey && value) { + if (section && value) { function scrollToSection() { if (!userHasCancelledAutoScroll) { scrollToRef({ @@ -86,11 +87,12 @@ const DropDownSection = ({ sectionRef, sectionHasFired, status, - objectKey, + section, setIsOpen, prevValue, documentContainerRef, userHasCancelledAutoScroll, + streamingTimeout, ]); return ( @@ -109,7 +111,7 @@ const DropDownSection = ({ setIsOpen(!isOpen)} aria-label="toggle"> - {sectionTitle(objectKey)} + {sectionTitle(section)} @@ -118,7 +120,7 @@ const DropDownSection = ({ {isOpen && (
{status === "isLoaded" ? ( - + ) : (

Loading

diff --git a/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/modify-button.tsx b/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/modify-button.tsx index 427042a61..b3ee39a57 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/modify-button.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/modify-button.tsx @@ -1,3 +1,4 @@ +import type { LessonPlanSectionWhileStreaming } from "@oakai/aila/src/protocol/schema"; import type { AilaUserModificationAction } from "@prisma/client"; import ActionButtonWrapper from "./action-button-wrapper"; @@ -7,7 +8,7 @@ import type { FeedbackOption } from "./drop-down-form-wrapper"; export type ModifyButtonProps = Readonly<{ sectionTitle: string; sectionPath: string; - sectionValue: Record | string | Array; + sectionValue: LessonPlanSectionWhileStreaming; }>; const ModifyButton = ({ diff --git a/apps/nextjs/src/components/AppComponents/Chat/export-buttons/LessonPlanProgressDropdown.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/export-buttons/LessonPlanProgressDropdown.stories.tsx index 1506c7b4d..a9cf19bff 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/export-buttons/LessonPlanProgressDropdown.stories.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/export-buttons/LessonPlanProgressDropdown.stories.tsx @@ -8,17 +8,18 @@ import type { Meta, StoryObj } from "@storybook/react"; import { LessonPlanProgressDropdown } from "./LessonPlanProgressDropdown"; -const meta: Meta = { +const meta = { title: "Components/LessonPlan/LessonPlanProgressDropdown", component: LessonPlanProgressDropdown, tags: ["autodocs"], -}; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { args: { + isStreaming: false, lessonPlan: { // 1 (lesson details) title: "Introduction to Glaciation", @@ -38,9 +39,9 @@ export const Default: Story = { learningOutcome: { current: null }, learningCycles: { current: null }, priorKnowledge: { current: null }, - "cycle-1": { current: null }, - "cycle-2": { current: null }, - "cycle-3": { current: null }, + cycle1: { current: null }, + cycle2: { current: null }, + cycle3: { current: null }, }, documentContainerRef: { current: null }, }, @@ -49,6 +50,7 @@ export const Default: Story = { export const PartiallyCompleted: Story = { args: { ...Default.args, + isStreaming: true, lessonPlan: { // 1 (lesson details) title: "Introduction to Glaciation", diff --git a/apps/nextjs/src/components/AppComponents/Chat/export-buttons/LessonPlanProgressDropdown.tsx b/apps/nextjs/src/components/AppComponents/Chat/export-buttons/LessonPlanProgressDropdown.tsx index 98f414d2e..218b19bbe 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/export-buttons/LessonPlanProgressDropdown.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/export-buttons/LessonPlanProgressDropdown.tsx @@ -1,6 +1,9 @@ import React, { useState } from "react"; -import type { LooseLessonPlan } from "@oakai/aila/src/protocol/schema"; +import type { + LessonPlanKeys, + LooseLessonPlan, +} from "@oakai/aila/src/protocol/schema"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import { Flex } from "@radix-ui/themes"; @@ -9,10 +12,12 @@ import { scrollToRef } from "@/utils/scrollToRef"; import { useProgressForDownloads } from "../Chat/hooks/useProgressForDownloads"; -type LessonPlanProgressDropdownProps = Readonly<{ +export type LessonPlanProgressDropdownProps = Readonly<{ lessonPlan: LooseLessonPlan; isStreaming: boolean; - sectionRefs: Record>; + sectionRefs: Partial< + Record> + >; documentContainerRef: React.MutableRefObject; }>; @@ -61,10 +66,10 @@ export const LessonPlanProgressDropdown: React.FC< disabled={!complete} className="mb-7 flex gap-6" onClick={() => { - if (key === "cycles" && complete) { - if (sectionRefs["cycle-1"]) { + if (key === "cycle1" && complete) { + if (sectionRefs["cycle1"]) { scrollToRef({ - ref: sectionRefs["cycle-1"], + ref: sectionRefs["cycle1"], containerRef: documentContainerRef, }); } diff --git a/apps/nextjs/src/components/AppComponents/Chat/export-buttons/MobileExportButtons.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/export-buttons/MobileExportButtons.stories.tsx index b4dad8f06..42d1045d5 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/export-buttons/MobileExportButtons.stories.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/export-buttons/MobileExportButtons.stories.tsx @@ -1,61 +1,41 @@ import type { Meta, StoryObj } from "@storybook/react"; -import type { ChatContextProps } from "@/components/ContextProviders/ChatProvider"; -import { ChatContext } from "@/components/ContextProviders/ChatProvider"; -import { DemoContext } from "@/components/ContextProviders/Demo"; +import { chromaticParams } from "@/storybook/chromatic"; +import { ChatDecorator } from "@/storybook/decorators/ChatDecorator"; +import { + DemoDecorator, + demoParams, +} from "@/storybook/decorators/DemoDecorator"; import { MobileExportButtons } from "./MobileExportButtons"; -const ChatDecorator: Story["decorators"] = (Story, { parameters }) => ( - - - -); - -const DemoDecorator: Story["decorators"] = (Story, { parameters }) => ( - - - -); - -const meta: Meta = { +const meta = { title: "Components/LessonPlan/MobileExportButtons", component: MobileExportButtons, tags: ["autodocs"], decorators: [ChatDecorator, DemoDecorator], parameters: { viewport: { - defaultViewport: "mobile1", + defaultViewport: "mobile", + }, + ...chromaticParams(["mobile"]), + ...demoParams({ isDemoUser: false }), + chatContext: { + id: "123", }, }, args: { closeMobileLessonPullOut: () => {}, }, -}; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = {}; export const SharingDisabled: Story = { parameters: { - demoContext: { - isDemoUser: true, - isSharingEnabled: false, - }, + ...demoParams({ isDemoUser: true, isSharingEnabled: false }), }, }; diff --git a/apps/nextjs/src/components/AppComponents/Chat/export-buttons/index.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/export-buttons/index.stories.tsx index 10df1a045..fc59a9a17 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/export-buttons/index.stories.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/export-buttons/index.stories.tsx @@ -1,38 +1,13 @@ import type { Meta, StoryObj } from "@storybook/react"; -import type { ChatContextProps } from "@/components/ContextProviders/ChatProvider"; -import { ChatContext } from "@/components/ContextProviders/ChatProvider"; -import { DemoContext } from "@/components/ContextProviders/Demo"; +import { ChatDecorator } from "@/storybook/decorators/ChatDecorator"; +import { + DemoDecorator, + demoParams, +} from "@/storybook/decorators/DemoDecorator"; import ExportButtons from "./"; -const ChatDecorator: Story["decorators"] = (Story, { parameters }) => ( - - - -); - -const DemoDecorator: Story["decorators"] = (Story, { parameters }) => ( - - - -); - const meta: Meta = { title: "Components/LessonPlan/ExportButtons", component: ExportButtons, @@ -42,10 +17,18 @@ const meta: Meta = { sectionRefs: {}, documentContainerRef: { current: null }, }, + parameters: { + chatContext: { + id: "123", + isStreaming: false, + lessonPlan: {}, + }, + ...demoParams({ isDemoUser: false }), + }, }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = {}; @@ -59,9 +42,6 @@ export const IsStreaming: Story = { export const SharingDisabled: Story = { parameters: { - demoContext: { - isDemoUser: true, - isSharingEnabled: false, - }, + ...demoParams({ isDemoUser: true, isSharingEnabled: false }), }, }; diff --git a/apps/nextjs/src/components/AppComponents/Chat/export-buttons/index.tsx b/apps/nextjs/src/components/AppComponents/Chat/export-buttons/index.tsx index 671e9ee56..e639b2dc1 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/export-buttons/index.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/export-buttons/index.tsx @@ -1,5 +1,6 @@ "use client"; +import type { LessonPlanKeys } from "@oakai/aila/src/protocol/schema"; import { OakSmallSecondaryButton } from "@oaknational/oak-components"; import Link from "next/link"; @@ -11,7 +12,9 @@ import { useDialog } from "../../DialogContext"; import { LessonPlanProgressDropdown } from "./LessonPlanProgressDropdown"; export type ExportButtonsProps = Readonly<{ - sectionRefs: Record>; + sectionRefs: Partial< + Record> + >; documentContainerRef: React.MutableRefObject; }>; @@ -26,7 +29,7 @@ const ExportButtons = ({ const demo = useDemoUser(); return ( -
+
= { +const meta = { title: "Components/Chat/GuidanceRequired", component: GuidanceRequired, tags: ["autodocs"], -}; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; const mockModeration: PersistedModerationBase = { id: "moderated", @@ -23,18 +23,19 @@ export const Default: Story = { }, }; -export const CustomClass: Story = { - args: { - moderation: mockModeration, - className: "custom-class", - }, -}; - -export const Safe: Story = { +export const NoModeration: Story = { args: { moderation: { id: "safe", categories: [], }, }, + decorators: [ + (Story) => ( + <> + +

(Nothing should render here)

+ + ), + ], }; diff --git a/apps/nextjs/src/components/AppComponents/Chat/header.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/header.stories.tsx new file mode 100644 index 000000000..7e39d249b --- /dev/null +++ b/apps/nextjs/src/components/AppComponents/Chat/header.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { chromaticParams } from "@/storybook/chromatic"; +import { + DemoDecorator, + demoParams, +} from "@/storybook/decorators/DemoDecorator"; + +import { Header } from "./header"; + +const meta = { + title: "Components/Layout/ChatHeader", + component: Header, + tags: ["autodocs"], + decorators: [DemoDecorator], + parameters: { + layout: "fullscreen", + docs: { + story: { + height: "150px", + }, + }, + ...demoParams({ isDemoUser: false }), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const DemoUser: Story = { + args: {}, + parameters: { + ...demoParams({ + isDemoUser: true, + demo: { + appSessionsPerMonth: 3, + appSessionsRemaining: 2, + }, + }), + ...chromaticParams(["desktop", "desktop-wide"]), + }, +}; + +export const DemoLoading: Story = { + args: {}, + parameters: { + ...demoParams({ + isDemoUser: true, + demo: { + appSessionsPerMonth: 3, + appSessionsRemaining: undefined, + }, + }), + }, +}; diff --git a/apps/nextjs/src/components/AppComponents/Chat/header.tsx b/apps/nextjs/src/components/AppComponents/Chat/header.tsx index 24ee90044..3fdf0b708 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/header.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/header.tsx @@ -22,7 +22,7 @@ import { OpenSideBarButton } from "./open-side-bar-button"; import { UserOrLogin } from "./user-or-login"; export function Header() { - const demo = useDemoUser(); + const { isDemoUser, demo } = useDemoUser(); // Check whether clerk metadata has loaded to prevent the banner from flashing const clerkMetadata = useClerkDemoMetadata(); @@ -36,7 +36,7 @@ export function Header() { $zIndex={"banner"} $width={"100%"} > - {clerkMetadata.isSet && demo.isDemoUser && ( + {clerkMetadata.isSet && isDemoUser && ( ; +// This could do with further refactoring to make it more readable +const createComponents = ( + className?: string, + lessonPlanSectionDescription?: string, +): Partial => ({ + li: ({ children }) => ( +
  • {children}
  • + ), + p: ({ children }) => ( +

    {children}

    + ), + h1: ({ children }) => ( + + +

    {children}

    +
    + {!!lessonPlanSectionDescription && ( + + + + + + + + + + {lessonPlanSectionDescription} + + + + + + )} +
    + ), + code: (props) => { + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + node, + className, + children, + inline, + ...restProps + } = props as { + node?: React.ReactNode; + inline?: boolean; + className?: string; + children?: React.ReactNode; + }; + if (children && Array.isArray(children) && children.length) { + if (children[0] == "▍") { + return ; + } + + children[0] = (children[0] as string).replace("`▍`", "▍"); + } + + const match = /language-(\w+)/.exec(className ?? ""); + + if (inline) { + return ( + + {children} + + ); + } + + return ( + + ); + }, + a: ({ children, href }) => { + const isExternal = href?.startsWith("http"); + const tags = isExternal + ? { target: "_blank", rel: "noopener noreferrer" } + : {}; + return ( + + {children} + + ); + }, +}); + export const MemoizedReactMarkdownWithStyles = ({ markdown, lessonPlanSectionDescription, className, }: ReactMarkdownWithStylesProps) => { + const components: Partial = useMemo(() => { + return createComponents(className, lessonPlanSectionDescription); + }, [className, lessonPlanSectionDescription]); return ( {children} - ); - }, - p({ children }) { - return

    {children}

    ; - }, - h1({ children }) { - return ( - - -

    {children}

    -
    - {!!lessonPlanSectionDescription && ( - - - - - - - - - - {lessonPlanSectionDescription} - - - - - - )} -
    - ); - }, - code(props) { - const { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - node, - className, - children, - inline, - ...restProps - } = props as { - node?: React.ReactNode; - inline?: boolean; - className?: string; - children?: React.ReactNode; - }; - if (children && Array.isArray(children) && children.length) { - if (children[0] == "▍") { - return ( - - ); - } - - children[0] = (children[0] as string).replace("`▍`", "▍"); - } - - const match = /language-(\w+)/.exec(className || ""); - - if (inline) { - return ( - - {children} - - ); - } - - return ( - - ); - }, - a({ children, href }) { - const isExternal = href?.startsWith("http"); - const tags = isExternal - ? { target: "_blank", rel: "noopener noreferrer" } - : {}; - return ( - - {children} - - ); - }, - }} + components={components} > {markdown}
    diff --git a/apps/nextjs/src/components/AppComponents/Chat/prompt-form.tsx b/apps/nextjs/src/components/AppComponents/Chat/prompt-form.tsx index 04e600d87..b2369b0ca 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/prompt-form.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/prompt-form.tsx @@ -17,8 +17,7 @@ import type { AilaStreamingStatus } from "./Chat/hooks/useAilaStreamingStatus"; export interface PromptFormProps extends Pick { onSubmit: (value: string) => void; - isEmptyScreen: boolean; - placeholder?: string; + hasMessages: boolean; ailaStreamingStatus: AilaStreamingStatus; queuedUserAction?: string | null; queueUserAction?: (action: string) => void; @@ -29,8 +28,7 @@ export function PromptForm({ onSubmit, input, setInput, - isEmptyScreen, - placeholder, + hasMessages, queuedUserAction, queueUserAction, }: Readonly) { @@ -93,8 +91,8 @@ export function PromptForm({ value={input} onChange={(e) => setInput(e.target.value)} placeholder={handlePlaceholder( - isEmptyScreen, - queuedUserAction ?? placeholder, + hasMessages, + queuedUserAction ?? undefined, )} spellCheck={false} className="min-h-[60px] w-full resize-none bg-transparent px-10 py-[1.3rem] text-base focus-within:outline-none" @@ -119,11 +117,14 @@ export function PromptForm({ ); } -function handlePlaceholder(isEmptyScreen: boolean, placeholder?: string) { - if (placeholder && !["continue", "regenerate"].includes(placeholder)) { - return placeholder; +function handlePlaceholder(hasMessages: boolean, queuedUserAction?: string) { + if ( + queuedUserAction && + !["continue", "regenerate"].includes(queuedUserAction) + ) { + return queuedUserAction; } - return !isEmptyScreen - ? "Type a subject, key stage and title" - : "Type your response here"; + return hasMessages + ? "Type your response here" + : "Type a subject, key stage and title"; } diff --git a/apps/nextjs/src/components/AppComponents/Chat/sidebar-actions.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/sidebar-actions.stories.tsx index b970589d9..772bee5bd 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/sidebar-actions.stories.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/sidebar-actions.stories.tsx @@ -3,17 +3,17 @@ import { within } from "@storybook/test"; import { SidebarActions } from "./sidebar-actions"; -const meta: Meta = { +const meta = { title: "Components/Sidebar/Actions", component: SidebarActions, parameters: { layout: "centered", }, tags: ["autodocs"], -}; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; const mockChat = { id: "1", diff --git a/apps/nextjs/src/components/AppComponents/Chat/ui/button.tsx b/apps/nextjs/src/components/AppComponents/Chat/ui/button.tsx index 9ce6ee098..5846852bc 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/ui/button.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/ui/button.tsx @@ -41,7 +41,7 @@ export interface ButtonProps readonly asChild?: boolean; } -const Button = React.forwardRef( +const Button = React.forwardRef>( ({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button"; return ( diff --git a/apps/nextjs/src/components/AppComponents/Chat/ui/codeblock.tsx b/apps/nextjs/src/components/AppComponents/Chat/ui/codeblock.tsx index 1633f7d0c..fc4fcfa01 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/ui/codeblock.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/ui/codeblock.tsx @@ -18,11 +18,11 @@ interface Props { value: string; } -interface languageMap { +interface LanguageMap { [key: string]: string | undefined; } -const programmingLanguages: languageMap = { +const programmingLanguages: LanguageMap = { javascript: ".js", python: ".py", java: ".java", @@ -65,7 +65,7 @@ const CodeBlock: FC = memo(({ language, value }) => { if (typeof window === "undefined") { return; } - const fileExtension = programmingLanguages[language] || ".file"; + const fileExtension = programmingLanguages[language] ?? ".file"; const suggestedFileName = `file-${generateRandomString( 3, true, diff --git a/apps/nextjs/src/components/AppComponents/Chat/ui/icons.tsx b/apps/nextjs/src/components/AppComponents/Chat/ui/icons.tsx index bb794b380..ba8d499f1 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/ui/icons.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/ui/icons.tsx @@ -4,7 +4,10 @@ import * as React from "react"; import { cn } from "@/lib/utils"; -function IconOpenAI({ className, ...props }: React.ComponentProps<"svg">) { +function IconOpenAI({ + className, + ...props +}: Readonly>) { return ( ) { ); } -function IconArrowDown({ className, ...props }: React.ComponentProps<"svg">) { +function IconArrowDown({ + className, + ...props +}: Readonly>) { return ( ) { ); } -function IconArrowRight({ className, ...props }: React.ComponentProps<"svg">) { +function IconArrowRight({ + className, + ...props +}: Readonly>) { return ( ) { ); } -function IconUser({ className, ...props }: React.ComponentProps<"svg">) { +function IconUser({ + className, + ...props +}: Readonly>) { return ( ) { ); } -function IconPlus({ className, ...props }: React.ComponentProps<"svg">) { +function IconPlus({ + className, + ...props +}: Readonly>) { return ( ) { ); } -function IconArrowElbow({ className, ...props }: React.ComponentProps<"svg">) { +function IconArrowElbow({ + className, + ...props +}: Readonly>) { return ( ) { ); } -function IconSpinner({ className, ...props }: React.ComponentProps<"svg">) { +function IconSpinner({ + className, + ...props +}: Readonly>) { return ( ) { ); } -function IconMessage({ className, ...props }: React.ComponentProps<"svg">) { +function IconMessage({ + className, + ...props +}: Readonly>) { return ( ) { ); } -function IconTrash({ className, ...props }: React.ComponentProps<"svg">) { +function IconTrash({ + className, + ...props +}: Readonly>) { return ( ) { ); } -function IconRefresh({ className, ...props }: React.ComponentProps<"svg">) { +function IconRefresh({ + className, + ...props +}: Readonly>) { return ( ) { ); } -function IconStop({ className, ...props }: React.ComponentProps<"svg">) { +function IconStop({ + className, + ...props +}: Readonly>) { return ( ) { ); } -function IconSidebar({ className, ...props }: React.ComponentProps<"svg">) { +function IconSidebar({ + className, + ...props +}: Readonly>) { return ( ) { ); } -function IconCopy({ className, ...props }: React.ComponentProps<"svg">) { +function IconCopy({ + className, + ...props +}: Readonly>) { return ( ) { ); } -function IconCheck({ className, ...props }: React.ComponentProps<"svg">) { +function IconCheck({ + className, + ...props +}: Readonly>) { return ( ) { ); } -function IconDownload({ className, ...props }: React.ComponentProps<"svg">) { +function IconDownload({ + className, + ...props +}: Readonly>) { return ( ) { ); } -function IconClose({ className, ...props }: React.ComponentProps<"svg">) { +function IconClose({ + className, + ...props +}: Readonly>) { return ( ) { ); } -function IconEdit({ className, ...props }: React.ComponentProps<"svg">) { +function IconEdit({ + className, + ...props +}: Readonly>) { return ( ) { ); } -function IconShare({ className, ...props }: React.ComponentProps<"svg">) { +function IconShare({ + className, + ...props +}: Readonly>) { return ( ) { ); } -function IconUsers({ className, ...props }: React.ComponentProps<"svg">) { +function IconUsers({ + className, + ...props +}: Readonly>) { return ( {} - -const Textarea = React.forwardRef( - ({ className, ...props }, ref) => { - return ( - - ); - }, -); +const Textarea = React.forwardRef< + HTMLTextAreaElement, + React.TextareaHTMLAttributes +>(({ className, ...props }, ref) => { + return ( + + ); +}); Textarea.displayName = "Textarea"; export { Textarea }; diff --git a/apps/nextjs/src/components/AppComponents/Chat/user-or-login.tsx b/apps/nextjs/src/components/AppComponents/Chat/user-or-login.tsx index 940a8f76d..f1cd37291 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/user-or-login.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/user-or-login.tsx @@ -10,17 +10,15 @@ export function UserOrLogin() { return <>; } return ( - <> -
    - - - - - - -
    - +
    + + + + + + +
    ); } diff --git a/apps/nextjs/src/components/AppComponents/ComparativeJudgement/KeyStageAndSubjectPicker.tsx b/apps/nextjs/src/components/AppComponents/ComparativeJudgement/KeyStageAndSubjectPicker.tsx index b5a039e53..c62dced01 100644 --- a/apps/nextjs/src/components/AppComponents/ComparativeJudgement/KeyStageAndSubjectPicker.tsx +++ b/apps/nextjs/src/components/AppComponents/ComparativeJudgement/KeyStageAndSubjectPicker.tsx @@ -54,7 +54,7 @@ const KeyStageAndSubjectPicker = ({ useEffect(() => { const havePickedUnavailableSubject = selectedSubject && !allowedSubjects.includes(selectedSubject); - if (havePickedUnavailableSubject || !selectedSubject) { + if (havePickedUnavailableSubject ?? !selectedSubject) { const firstAvailableSubject = allowedSubjects[0]; if (selectedSubject !== firstAvailableSubject) { diff --git a/apps/nextjs/src/components/AppComponents/FeedbackForms/ModerationFeedbackForm.tsx b/apps/nextjs/src/components/AppComponents/FeedbackForms/ModerationFeedbackForm.tsx index 22366e46a..c77be1132 100644 --- a/apps/nextjs/src/components/AppComponents/FeedbackForms/ModerationFeedbackForm.tsx +++ b/apps/nextjs/src/components/AppComponents/FeedbackForms/ModerationFeedbackForm.tsx @@ -46,39 +46,46 @@ export const ModerationFeedbackForm = ({ />
    - <> -
    - - Guidance required - - - - Contains{" "} - {moderation.categories - .map(moderationSlugToDescription) - .join(", ")} - . Check content carefully. If you have feedback on this guidance, - please provide details below. - - -
    -
    -