diff options
author | Christian Fehmer <[email protected]> | 2024-06-17 15:21:55 +0200 |
---|---|---|
committer | GitHub <[email protected]> | 2024-06-17 15:21:55 +0200 |
commit | b4ea7f119f5b9ee68f8e3927c64c856e6a6b0361 (patch) | |
tree | 765cd976a49973be231862f8f7a0db16489e0208 | |
parent | 57a6fd9bd5cd4eb6fb65b387926434b0c0767ff9 (diff) | |
download | monkeytype-b4ea7f119f5b9ee68f8e3927c64c856e6a6b0361.tar.gz monkeytype-b4ea7f119f5b9ee68f8e3927c64c856e6a6b0361.zip |
impr(dev): add endpoint to create test user/data (fehmer) (#5396)
!nuf
-rw-r--r-- | backend/src/api/controllers/dev.ts | 388 | ||||
-rw-r--r-- | backend/src/api/routes/dev.ts | 38 | ||||
-rw-r--r-- | backend/src/api/routes/index.ts | 4 | ||||
-rw-r--r-- | backend/src/dal/result.ts | 48 | ||||
-rw-r--r-- | backend/src/dal/user.ts | 2 | ||||
-rw-r--r-- | frontend/__tests__/utils/tag-builder.spec.ts | 51 | ||||
-rw-r--r-- | frontend/src/html/popups.html | 7 | ||||
-rw-r--r-- | frontend/src/styles/core.scss | 11 | ||||
-rw-r--r-- | frontend/src/styles/media-queries-purple.scss | 4 | ||||
-rw-r--r-- | frontend/src/styles/popups.scss | 40 | ||||
-rw-r--r-- | frontend/src/ts/ape/endpoints/dev.ts | 15 | ||||
-rw-r--r-- | frontend/src/ts/ape/endpoints/index.ts | 2 | ||||
-rw-r--r-- | frontend/src/ts/ape/index.ts | 1 | ||||
-rw-r--r-- | frontend/src/ts/ape/types/dev.d.ts | 14 | ||||
-rw-r--r-- | frontend/src/ts/controllers/theme-controller.ts | 6 | ||||
-rw-r--r-- | frontend/src/ts/index.ts | 4 | ||||
-rw-r--r-- | frontend/src/ts/modals/dev-options.ts | 34 | ||||
-rw-r--r-- | frontend/src/ts/modals/simple-modals.ts | 396 | ||||
-rw-r--r-- | frontend/src/ts/ready.ts | 4 | ||||
-rw-r--r-- | frontend/src/ts/utils/async-modules.ts | 28 | ||||
-rw-r--r-- | frontend/src/ts/utils/tag-builder.ts | 31 |
21 files changed, 994 insertions, 134 deletions
diff --git a/backend/src/api/controllers/dev.ts b/backend/src/api/controllers/dev.ts new file mode 100644 index 000000000..6c4e854d5 --- /dev/null +++ b/backend/src/api/controllers/dev.ts @@ -0,0 +1,388 @@ +import { MonkeyResponse } from "../../utils/monkey-response"; +import * as UserDal from "../../dal/user"; +import FirebaseAdmin from "../../init/firebase-admin"; +import Logger from "../../utils/logger"; +import * as DateUtils from "date-fns"; +import { UTCDate } from "@date-fns/utc"; +import * as ResultDal from "../../dal/result"; +import { roundTo2 } from "../../utils/misc"; +import { ObjectId } from "mongodb"; +import * as LeaderboardDal from "../../dal/leaderboards"; +import { isNumber } from "lodash"; +import MonkeyError from "../../utils/error"; + +type GenerateDataOptions = { + firstTestTimestamp: Date; + lastTestTimestamp: Date; + minTestsPerDay: number; + maxTestsPerDay: number; +}; + +const CREATE_RESULT_DEFAULT_OPTIONS: GenerateDataOptions = { + firstTestTimestamp: DateUtils.startOfDay(new UTCDate(Date.now())), + lastTestTimestamp: DateUtils.endOfDay(new UTCDate(Date.now())), + minTestsPerDay: 0, + maxTestsPerDay: 50, +}; + +export async function createTestData( + req: MonkeyTypes.Request +): Promise<MonkeyResponse> { + const { username, createUser } = req.body; + const user = await getOrCreateUser(username, "password", createUser); + + const { uid, email } = user; + + await createTestResults(user, req.body); + await updateUser(uid); + await updateLeaderboard(); + + return new MonkeyResponse("test data created", { uid, email }, 200); +} + +async function getOrCreateUser( + username: string, + password: string, + createUser = false +): Promise<MonkeyTypes.DBUser> { + const existingUser = await UserDal.findByName(username); + + if (existingUser !== undefined && existingUser !== null) { + return existingUser; + } else if (createUser === false) { + throw new MonkeyError(404, `User ${username} does not exist.`); + } + + const email = username + "@example.com"; + Logger.success("create user " + username); + const { uid } = await FirebaseAdmin().auth().createUser({ + displayName: username, + password: password, + email, + emailVerified: true, + }); + + await UserDal.addUser(username, email, uid); + return UserDal.getUser(uid, "getOrCreateUser"); +} + +async function createTestResults( + user: MonkeyTypes.DBUser, + configOptions: Partial<GenerateDataOptions> +): Promise<void> { + const config = { + ...CREATE_RESULT_DEFAULT_OPTIONS, + ...configOptions, + }; + if (isNumber(config.firstTestTimestamp)) + config.firstTestTimestamp = toDate(config.firstTestTimestamp); + if (isNumber(config.lastTestTimestamp)) + config.lastTestTimestamp = toDate(config.lastTestTimestamp); + + const days = DateUtils.eachDayOfInterval({ + start: config.firstTestTimestamp, + end: config.lastTestTimestamp, + }).map((day) => ({ + timestamp: DateUtils.startOfDay(day), + amount: Math.round(random(config.minTestsPerDay, config.maxTestsPerDay)), + })); + + for (const day of days) { + Logger.success( + `User ${user.name} insert ${day.amount} results on ${new Date( + day.timestamp + )}` + ); + const results = createArray(day.amount, () => + createResult(user, day.timestamp) + ); + if (results.length > 0) + await ResultDal.getResultCollection().insertMany(results); + } +} + +function toDate(value: number): Date { + return new UTCDate(value); +} + +function random(min: number, max: number): number { + return roundTo2(Math.random() * (max - min) + min); +} + +function createResult( + user: MonkeyTypes.DBUser, + timestamp: Date //evil, we modify this value +): MonkeyTypes.DBResult { + const mode: SharedTypes.Config.Mode = randomValue(["time", "words"]); + const mode2: number = + mode === "time" + ? randomValue([15, 30, 60, 120]) + : randomValue([10, 25, 50, 100]); + const testDuration = mode2; + + timestamp = DateUtils.addSeconds(timestamp, testDuration); + return { + _id: new ObjectId(), + uid: user.uid, + wpm: random(80, 120), + rawWpm: random(80, 120), + charStats: [131, 0, 0, 0], + acc: random(80, 100), + language: "english", + mode: mode as SharedTypes.Config.Mode, + mode2: mode2 as unknown as never, + timestamp: timestamp.valueOf(), + testDuration: testDuration, + consistency: random(80, 100), + keyConsistency: 33.18, + chartData: { + wpm: createArray(testDuration, () => random(80, 120)), + raw: createArray(testDuration, () => random(80, 120)), + err: createArray(testDuration, () => (Math.random() < 0.1 ? 1 : 0)), + }, + keySpacingStats: { + average: 113.88, + sd: 77.3, + }, + keyDurationStats: { + average: 107.13, + sd: 39.86, + }, + isPb: Math.random() < 0.1, + name: user.name, + }; +} + +async function updateUser(uid: string): Promise<void> { + //update timetyping and completedTests + const stats = await ResultDal.getResultCollection() + .aggregate([ + { + $match: { + uid, + }, + }, + { + $group: { + _id: { + language: "$language", + mode: "$mode", + mode2: "$mode2", + }, + timeTyping: { + $sum: "$testDuration", + }, + completedTests: { + $count: {}, + }, + }, + }, + ]) + .toArray(); + + const timeTyping = stats.reduce((a, c) => a + c["timeTyping"], 0); + const completedTests = stats.reduce((a, c) => a + c["completedTests"], 0); + + //update PBs + const lbPersonalBests: MonkeyTypes.LbPersonalBests = { + time: { + 15: {}, + 60: {}, + }, + }; + + const personalBests: SharedTypes.PersonalBests = { + time: {}, + custom: {}, + words: {}, + zen: {}, + quote: {}, + }; + const modes = stats.map((it) => it["_id"]); + for (const mode of modes) { + const best = ( + await ResultDal.getResultCollection() + .find({ + uid, + language: mode.language, + mode: mode.mode, + mode2: mode.mode2, + }) + .sort({ wpm: -1, timestamp: 1 }) + .limit(1) + .toArray() + )[0] as MonkeyTypes.DBResult; + + if (personalBests[mode.mode] === undefined) personalBests[mode.mode] = {}; + if (personalBests[mode.mode][mode.mode2] === undefined) + personalBests[mode.mode][mode.mode2] = []; + + const entry = { + acc: best.acc, + consistency: best.consistency, + difficulty: best.difficulty ?? "normal", + lazyMode: best.lazyMode, + language: mode.language, + punctuation: best.punctuation, + raw: best.rawWpm, + wpm: best.wpm, + numbers: best.numbers, + timestamp: best.timestamp, + } as SharedTypes.PersonalBest; + + personalBests[mode.mode][mode.mode2].push(entry); + + if (mode.mode === "time") { + if (lbPersonalBests[mode.mode][mode.mode2] === undefined) + lbPersonalBests[mode.mode][mode.mode2] = {}; + + lbPersonalBests[mode.mode][mode.mode2][mode.language] = entry; + } + + //update testActivity + await updateTestActicity(uid); + } + + //update the user + await UserDal.getUsersCollection().updateOne( + { uid }, + { + $set: { + timeTyping: timeTyping, + completedTests: completedTests, + startedTests: Math.round(completedTests * 1.25), + personalBests: personalBests as SharedTypes.PersonalBests, + lbPersonalBests: lbPersonalBests, + }, + } + ); +} + +async function updateLeaderboard(): Promise<void> { + await LeaderboardDal.update("time", "15", "english"); + await LeaderboardDal.update("time", "60", "english"); +} + +function randomValue<T>(values: T[]): T { + const rnd = Math.round(Math.random() * (values.length - 1)); + return values[rnd] as T; +} + +function createArray<T>(size: number, builder: () => T): T[] { + return new Array(size).fill(0).map((it) => builder()); +} + +async function updateTestActicity(uid: string): Promise<void> { + await ResultDal.getResultCollection() + .aggregate( + [ + { + $match: { + uid, + }, + }, + { + $project: { + _id: 0, + timestamp: -1, + uid: 1, + }, + }, + { + $addFields: { + date: { + $toDate: "$timestamp", + }, + }, + }, + { + $replaceWith: { + uid: "$uid", + year: { + $year: "$date", + }, + day: { + $dayOfYear: "$date", + }, + }, + }, + { + $group: { + _id: { + uid: "$uid", + year: "$year", + day: "$day", + }, + count: { + $sum: 1, + }, + }, + }, + { + $group: { + _id: { + uid: "$_id.uid", + year: "$_id.year", + }, + days: { + $addToSet: { + day: "$_id.day", + tests: "$count", + }, + }, + }, + }, + { + $replaceWith: { + uid: "$_id.uid", + days: { + $function: { + lang: "js", + args: ["$days", "$_id.year"], + body: `function (days, year) { + var max = Math.max( + ...days.map((it) => it.day) + )-1; + var arr = new Array(max).fill(null); + for (day of days) { + arr[day.day-1] = day.tests; + } + let result = {}; + result[year] = arr; + return result; + }`, + }, + }, + }, + }, + { + $group: { + _id: "$uid", + testActivity: { + $mergeObjects: "$days", + }, + }, + }, + { + $addFields: { + uid: "$_id", + }, + }, + { + $project: { + _id: 0, + }, + }, + { + $merge: { + into: "users", + on: "uid", + whenMatched: "merge", + whenNotMatched: "discard", + }, + }, + ], + { allowDiskUse: true } + ) + .toArray(); +} diff --git a/backend/src/api/routes/dev.ts b/backend/src/api/routes/dev.ts new file mode 100644 index 000000000..b251f58e2 --- /dev/null +++ b/backend/src/api/routes/dev.ts @@ -0,0 +1,38 @@ +import { Router } from "express"; +import { authenticateRequest } from "../../middlewares/auth"; +import { + asyncHandler, + validateConfiguration, + validateRequest, +} from "../../middlewares/api-utils"; +import joi from "joi"; +import { createTestData } from "../controllers/dev"; +import { isDevEnvironment } from "../../utils/misc"; + +const router = Router(); + +router.use( + validateConfiguration({ + criteria: () => { + return isDevEnvironment(); + }, + invalidMessage: "Development endpoints are only available in DEV mode.", + }) +); + +router.post( + "/generateData", + validateRequest({ + body: { + username: joi.string().required(), + createUser: joi.boolean().optional(), + firstTestTimestamp: joi.number().optional(), + lastTestTimestamp: joi.number().optional(), + minTestsPerDay: joi.number().optional(), + maxTestsPerDay: joi.number().optional(), + }, + }), + asyncHandler(createTestData) +); + +export default router; diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index 04ad80b25..44c92c133 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -10,6 +10,7 @@ import presets from "./presets"; import apeKeys from "./ape-keys"; import admin from "./admin"; import webhooks from "./webhooks"; +import dev from "./dev"; import configuration from "./configuration"; import { version } from "../../version"; import leaderboards from "./leaderboards"; @@ -67,6 +68,9 @@ function addApiRoutes(app: Application): void { } next(); }); + + //enable dev edpoints + app.use("/dev", dev); } // Cannot be added to the route map because it needs to be added before the maintenance handler diff --git a/backend/src/dal/result.ts b/backend/src/dal/result.ts index 85128d109..5e19576bb 100644 --- a/backend/src/dal/result.ts +++ b/backend/src/dal/result.ts @@ -1,10 +1,17 @@ import _ from "lodash"; -import { DeleteResult, ObjectId, UpdateResult } from "mongodb"; +import { Collection, DeleteResult, ObjectId, UpdateResult } from "mongodb"; import MonkeyError from "../utils/error"; import * as db from "../init/db"; import { getUser, getTags } from "./user"; +type DBResult = MonkeyTypes.WithObjectId< + SharedTypes.DBResult<SharedTypes.Config.Mode> +>; + +export const getResultCollection = (): Collection<DBResult> => + db.collection<DBResult>("results"); + export async function addResult( uid: string, result: MonkeyTypes.DBResult @@ -18,18 +25,14 @@ export async function addResult( if (!user) throw new MonkeyError(404, "User not found", "add result"); if (result.uid === undefined) result.uid = uid; // result.ir = true; - const res = await db - .collection<MonkeyTypes.DBResult>("results") - .insertOne(result); + const res = await getResultCollection().insertOne(result); return { insertedId: res.insertedId, }; } export async function deleteAll(uid: string): Promise<DeleteResult> { - return await db - .collection<MonkeyTypes.DBResult>("results") - .deleteMany({ uid }); + return await getResultCollection().deleteMany({ uid }); } export async function updateTags( @@ -37,9 +40,10 @@ export async function updateTags( resultId: string, tags: string[] ): Promise<UpdateResult> { - const result = await db - .collection<MonkeyTypes.DBResult>("results") - .findOne({ _id: new ObjectId(resultId), uid }); + const result = await getResultCollection().findOne({ + _id: new ObjectId(resultId), + uid, + }); if (!result) throw new MonkeyError(404, "Result not found"); const userTags = await getTags(uid); const userTagIds = userTags.map((tag) => tag._id.toString()); @@ -50,18 +54,20 @@ export async function updateTags( if (!validTags) { throw new MonkeyError(422, "One of the tag id's is not valid"); } - return await db - .collection<MonkeyTypes.DBResult>("results") - .updateOne({ _id: new ObjectId(resultId), uid }, { $set: { tags } }); + return await getResultCollection().updateOne( + { _id: new ObjectId(resultId), uid }, + { $set: { tags } } + ); } export async function getResult( uid: string, id: string ): Promise<MonkeyTypes.DBResult> { - const result = await db - .collection<MonkeyTypes.DBResult>("results") - .findOne({ _id: new ObjectId(id), uid }); + const result = await getResultCollection().findOne({ + _id: new ObjectId(id), + uid, + }); if (!result) throw new MonkeyError(404, "Result not found"); return result; } @@ -69,8 +75,7 @@ export async function getResult( export async function getLastResult( uid: string ): Promise<Omit<MonkeyTypes.DBResult, "uid">> { - const [lastResult] = await db - .collection<MonkeyTypes.DBResult>("results") + const [lastResult] = await getResultCollection() .find({ uid }) .sort({ timestamp: -1 }) .limit(1) @@ -83,9 +88,7 @@ export async function getResultByTimestamp( uid: string, timestamp ): Promise<MonkeyTypes.DBResult | null> { - return await db - .collection<MonkeyTypes.DBResult>("results") - .findOne({ uid, timestamp }); + return await getResultCollection().findOne({ uid, timestamp }); } type GetResultsOpts = { @@ -99,8 +102,7 @@ export async function getResults( opts?: GetResultsOpts ): Promise<MonkeyTypes.DBResult[]> { const { onOrAfterTimestamp, offset, limit } = opts ?? {}; - let query = db - .collection<MonkeyTypes.DBResult>("results") + let query = getResultCollection() .find({ uid, ...(!_.isNil(onOrAfterTimestamp) && diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index d70d96453..f3c47eb92 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -202,7 +202,7 @@ export async function getUser( return user; } -async function findByName( +export async function findByName( name: string ): Promise<MonkeyTypes.DBUser | undefined> { return ( diff --git a/frontend/__tests__/utils/tag-builder.spec.ts b/frontend/__tests__/utils/tag-builder.spec.ts new file mode 100644 index 000000000..ed7340f29 --- /dev/null +++ b/frontend/__tests__/utils/tag-builder.spec.ts @@ -0,0 +1,51 @@ +import { buildTag } from "../../src/ts/utils/tag-builder"; + +describe("simple-modals", () => { + describe("buildTag", () => { + it("builds with mandatory", () => { + expect(buildTag({ tagname: "input" })).toBe("<input />"); + }); + it("builds with classes", () => { + expect(buildTag({ tagname: "input", classes: ["hidden", "bold"] })).toBe( + '<input class="hidden bold" />' + ); + }); + it("builds with attributes", () => { + expect( + buildTag({ + tagname: "input", + attributes: { + id: "4711", + oninput: "console.log()", + required: true, + checked: true, + missing: undefined, + }, + }) + ).toBe('<input checked id="4711" oninput="console.log()" required />'); + }); + + it("builds with innerHtml", () => { + expect( + buildTag({ tagname: "textarea", innerHTML: "<h1>Hello</h1>" }) + ).toBe("<textarea><h1>Hello</h1></textarea>"); + }); + it("builds with everything", () => { + expect( + buildTag({ + tagname: "textarea", + classes: ["hidden", "bold"], + attributes: { + id: "4711", + oninput: "console.log()", + readonly: true, + required: true, + }, + innerHTML: "<h1>Hello</h1>", + }) + ).toBe( + '<textarea class="hidden bold" id="4711" oninput="console.log()" readonly required><h1>Hello</h1></textarea>' + ); + }); + }); +}); diff --git a/frontend/src/html/popups.html b/frontend/src/html/popups.html index 0f073bf09..6b0cdbc3f 100644 --- a/frontend/src/html/popups.html +++ b/frontend/src/html/popups.html @@ -4,6 +4,13 @@ </div> </dialog> +<dialog id="devOptionsModal" class="modalWrapper hidden"> + <div class="modal"> + <div class="title">Dev options</div> + <button class="generateData">generate data</button> + </div> +</dialog> + <dialog id="alertsPopup" class="modalWrapper hidden"> <div class="modal"> <button class="mobileClose"> diff --git a/frontend/src/styles/core.scss b/frontend/src/styles/core.scss index 3e88d85c2..f5b104425 100644 --- a/frontend/src/styles/core.scss +++ b/frontend/src/styles/core.scss @@ -334,17 +334,22 @@ key { } } -.configureAPI.button { +#devButtons { position: fixed; left: 0; top: 10rem; display: grid; - grid-auto-flow: column; + grid-auto-flow: row; gap: 0.5rem; text-decoration: none; z-index: 999999999; border-radius: 0 1rem 1rem 0; - padding: 1rem; + + .button { + padding: 1rem; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } } .avatar { diff --git a/frontend/src/styles/media-queries-purple.scss b/frontend/src/styles/media-queries-purple.scss index 3fe6862bb..2bd4886fb 100644 --- a/frontend/src/styles/media-queries-purple.scss +++ b/frontend/src/styles/media-queries-purple.scss @@ -273,4 +273,8 @@ aspect-ratio: 1; } } + .popupWrapper .modal .inputs.withLabel, + .modalWrapper .modal .inputs.withLabel { + grid-template-columns: 1fr; + } } diff --git a/frontend/src/styles/popups.scss b/frontend/src/styles/popups.scss index 1e46c0fcf..b16446eab 100644 --- a/frontend/src/styles/popups.scss +++ b/frontend/src/styles/popups.scss @@ -41,6 +41,37 @@ font-size: 1.5rem; color: var(--sub-color); } + + .inputs.withLabel { + display: grid; + grid-template-columns: max-content auto; + grid-auto-flow: row; + } + + .inputs { + div:has(> input[type="range"]) { + display: grid; + grid-auto-columns: auto 3rem; + grid-auto-flow: column; + gap: 0.5rem; + + span { + text-align: right; + } + } + } + } +} + +body.darkMode { + .popupWrapper, + .modalWrapper { + .modal .inputs { + input[type="date"]::-webkit-calendar-picker-indicator, + input[type="datetime-local"]::-webkit-calendar-picker-indicator { + filter: invert(1); + } + } } } @@ -486,6 +517,9 @@ opacity: 1; } } + & [data-popup-id="devGenerateData"] { + max-width: 700px; + } } #mobileTestConfigModal { @@ -594,6 +628,12 @@ } } +#devOptionsModal { + .modal { + max-width: 400px; + } +} + #shareTestSettingsModal { .modal { max-width: 600px; diff --git a/frontend/src/ts/ape/endpoints/dev.ts b/frontend/src/ts/ape/endpoints/dev.ts new file mode 100644 index 000000000..0128ed376 --- /dev/null +++ b/frontend/src/ts/ape/endpoints/dev.ts @@ -0,0 +1,15 @@ +const BASE_PATH = "/dev"; + +export default class Dev { + constructor(private httpClient: Ape.HttpClient) { + this.httpClient = httpClient; + } + + async generateData( + params: Ape.Dev.GenerateData + ): Ape.EndpointResponse<Ape.Dev.GenerateDataResponse> { + return await this.httpClient.post(BASE_PATH + "/generateData", { + payload: params, + }); + } +} diff --git a/frontend/src/ts/ape/endpoints/index.ts b/frontend/src/ts/ape/endpoints/index.ts index b6ee0762c..8656133c4 100644 --- a/frontend/src/ts/ape/endpoints/index.ts +++ b/frontend/src/ts/ape/endpoints/index.ts @@ -8,6 +8,7 @@ import Users from "./users"; import ApeKeys from "./ape-keys"; import Public from "./public"; import Configuration from "./configuration"; +import Dev from "./dev"; export default { Configs, @@ -20,4 +21,5 @@ export default { Users, ApeKeys, Configuration, + Dev, }; diff --git a/frontend/src/ts/ape/index.ts b/frontend/src/ts/ape/index.ts index c456f159b..30fa84b3a 100644 --- a/frontend/src/ts/ape/index.ts +++ b/frontend/src/ts/ape/index.ts @@ -20,6 +20,7 @@ const Ape = { publicStats: new endpoints.Public(httpClient), apeKeys: new endpoints.ApeKeys(httpClient), configuration: new endpoints.Configuration(httpClient), + dev: new endpoints.Dev(buildHttpClient(API_URL, 240_000)), }; export default Ape; diff --git a/frontend/src/ts/ape/types/dev.d.ts b/frontend/src/ts/ape/types/dev.d.ts new file mode 100644 index 000000000..9d3755a3b --- /dev/null +++ b/frontend/src/ts/ape/types/dev.d.ts @@ -0,0 +1,14 @@ +declare namespace Ape.Dev { + type GenerateData = { + username: string; + createUser?: boolean; + firstTestTimestamp?: number; + lastTestTimestamp?: number; + minTestsPerDay?: number; + maxTestsPerDay?: number; + }; + type GenerateDataResponse = { + uid: string; + email: string; + }; +} diff --git a/frontend/src/ts/controllers/theme-controller.ts b/frontend/src/ts/controllers/theme-controller.ts index c16c25124..005374069 100644 --- a/frontend/src/ts/controllers/theme-controller.ts +++ b/frontend/src/ts/controllers/theme-controller.ts @@ -181,6 +181,12 @@ async function apply( $("#metaThemeColor").attr("content", colors.bg); // } updateFooterThemeName(isPreview ? themeName : undefined); + + if (isColorDark(await ThemeColors.get("bg"))) { + $("body").addClass("darkMode"); + } else { + $("body").removeClass("darkMode"); + } } function updateFooterThemeName(nameOverride?: string): void { diff --git a/frontend/src/ts/index.ts b/frontend/src/ts/index.ts index b59c20b2f..73c8060bf 100644 --- a/frontend/src/ts/index.ts +++ b/frontend/src/ts/index.ts @@ -42,6 +42,7 @@ import "./controllers/profile-search-controller"; import { isDevEnvironment } from "./utils/misc"; import * as VersionButton from "./elements/version-button"; import * as Focus from "./test/focus"; +import { getDevOptionsModal } from "./utils/async-modules"; function addToGlobal(items: Record<string, unknown>): void { for (const [name, item] of Object.entries(items)) { @@ -72,4 +73,7 @@ if (isDevEnvironment()) { void import("jquery").then((jq) => { addToGlobal({ $: jq.default }); }); + void getDevOptionsModal().then((module) => { + module.appendButton(); + }); } diff --git a/frontend/src/ts/modals/dev-options.ts b/frontend/src/ts/modals/dev-options.ts new file mode 100644 index 000000000..da055648e --- /dev/null +++ b/frontend/src/ts/modals/dev-options.ts @@ -0,0 +1,34 @@ +import { envConfig } from "../constants/env-config"; +import AnimatedModal from "../utils/animated-modal"; +import { showPopup } from "./simple-modals"; + +export function show(): void { + void modal.show(); +} + +async function setup(modalEl: HTMLElement): Promise<void> { + modalEl.querySelector(".generateData")?.addEventListener("click", () => { + showPopup("devGenerateData"); + }); +} + +const modal = new AnimatedModal({ + dialogId: "devOptionsModal", + setup, +}); + +export function appendButton(): void { + $("body").prepend( + ` + <div id="devButtons"> + <a class='button configureAPI' href='${envConfig.backendUrl}/configure/' target='_blank' aria-label="Configure API" data-balloon-pos="right"><i class="fas fa-fw fa-server"></i></a> + <button class='button showDevOptionsModal' aria-label="Dev options" data-balloon-pos="right"><i class="fas fa-fw fa-flask"></i></button> + <div> + ` + ); + document + .querySelector("#devButtons .button.showDevOptionsModal") + ?.addEventListener("click", () => { + show(); + }); +} diff --git a/frontend/src/ts/modals/simple-modals.ts b/frontend/src/ts/modals/simple-modals.ts index c224de9ac..5a7772df0 100644 --- a/frontend/src/ts/modals/simple-modals.ts +++ b/frontend/src/ts/modals/simple-modals.ts @@ -31,16 +31,62 @@ import AnimatedModal, { HideOptions, ShowOptions, } from "../utils/animated-modal"; +import { format as dateFormat } from "date-fns/format"; +import { Attributes, buildTag } from "../utils/tag-builder"; -type Input = { +type CommonInput<TType, TValue> = { + type: TType; + initVal?: TValue; placeholder?: string; - type?: string; - initVal: string; hidden?: boolean; disabled?: boolean; + optional?: boolean; label?: string; + oninput?: (event: Event) => void; }; +type TextInput = CommonInput<"text", string>; +type TextArea = CommonInput<"textarea", string>; +type PasswordInput = CommonInput<"password", string>; +type EmailInput = CommonInput<"email", string>; + +type RangeInput = { + min: number; + max: number; + step?: number; +} & CommonInput<"range", number>; + +type DateTimeInput = { + min?: Date; + max?: Date; +} & CommonInput<"datetime-local", Date>; +type DateInput = { + min?: Date; + max?: Date; +} & CommonInput<"date", Date>; + +type CheckboxInput = { + label: string; + placeholder?: never; + description?: string; +} & CommonInput<"checkbox", boolean>; + +type NumberInput = { + min?: number; + max?: number; +} & CommonInput<"number", number>; + +type CommonInputType = + | TextInput + | TextArea + | PasswordInput + | EmailInput + | RangeInput + | DateTimeInput + | DateInput + | CheckboxInput + | NumberInput; + let activePopup: SimpleModal | null = null; type ExecReturn = { @@ -78,7 +124,8 @@ type PopupKey = | "resetProgressCustomTextLong" | "updateCustomTheme" | "deleteCustomTheme" - | "forgotPassword"; + | "forgotPassword" + | "devGenerateData"; const list: Record<PopupKey, SimpleModal | undefined> = { updateEmail: undefined, @@ -106,13 +153,13 @@ const list: Record<PopupKey, SimpleModal | undefined> = { updateCustomTheme: undefined, deleteCustomTheme: undefined, forgotPassword: undefined, + devGenerateData: undefined, }; type SimpleModalOptions = { id: string; - type: string; title: string; - inputs?: Input[]; + inputs?: CommonInputType[]; text?: string; buttonText: string; execFn: (thisPopup: SimpleModal, ...params: string[]) => Promise<ExecReturn>; @@ -121,6 +168,7 @@ type SimpleModalOptions = { canClose?: boolean; onlineOnly?: boolean; hideCallsExec?: boolean; + showLabels?: boolean; }; const modal = new AnimatedModal({ @@ -144,9 +192,8 @@ class SimpleModal { wrapper: HTMLElement; element: HTMLElement; id: string; - type: string; title: string; - inputs: Input[]; + inputs: CommonInputType[]; text?: string; buttonText: string; execFn: (thisPopup: SimpleModal, ...params: string[]) => Promise<ExecReturn>; @@ -155,10 +202,10 @@ class SimpleModal { canClose: boolean; onlineOnly: boolean; hideCallsExec: boolean; + showLabels: boolean; constructor(options: SimpleModalOptions) { this.parameters = []; this.id = options.id; - this.type = options.type; this.execFn = options.execFn; this.title = options.title; this.inputs = options.inputs ?? []; @@ -171,6 +218,7 @@ class SimpleModal { this.canClose = options.canClose ?? true; this.onlineOnly = options.onlineOnly ?? false; this.hideCallsExec = options.hideCallsExec ?? false; + this.showLabels = options.showLabels ?? false; } reset(): void { this.element.innerHTML = ` @@ -214,68 +262,138 @@ class SimpleModal { return; } - if (this.type === "number") { - this.inputs.forEach((input) => { - el.find(".inputs").append(` - <input - type="number" - min="1" - value="${input.initVal}" - placeholder="${input.placeholder}" - class="${input.hidden ? "hidden" : ""}" - ${input.hidden ? "" : "required"} - autocomplete="off" - > + const inputs = el.find(".inputs"); + if (this.showLabels) inputs.addClass("withLabel"); + + this.inputs.forEach((input, index) => { + const id = `${this.id}_${index}`; + + if (this.showLabels && !input.hidden) { + inputs.append(`<label for="${id}">${input.label ?? ""}</label>`); + } + + const tagname = input.type === "textarea" ? "textarea" : "input"; + const classes = input.hidden ? ["hidden"] : undefined; + const attributes: Attributes = { + id: id, + placeholder: input.placeholder ?? "", + autocomplete: "off", + }; + + if (input.type !== "textarea") { + attributes["value"] = input.initVal?.toString() ?? ""; + attributes["type"] = input.type; + } + if (!input.hidden && !input.optional === true) { + attributes["required"] = true; + } + if (input.disabled) { + attributes["disabled"] = true; + } + + if (input.type === "textarea") { + inputs.append( + buildTag({ + tagname, + classes, + attributes, + innerHTML: input.initVal, + }) + ); + } else if (input.type === "checkbox") { + let html = ` + <input + id="${id}" + type="checkbox" + class="${input.hidden ? "hidden" : ""}" + ${input.initVal ? 'checked="checked"' : ""}> + `; + if (input.description !== undefined) { + html += `<span>${input.description}</span>`; + } + if (!this.showLabels) { + html = ` + <label class="checkbox"> + ${html} + <div>${input.label}</div> + </label> + `; + } else { + html = `<div>${html}</div>`; + } + inputs.append(html); + } else if (input.type === "range") { + inputs.append(` + <div> + ${buildTag({ + tagname, + classes, + attributes: { + ...attributes, + min: input.min.toString(), + max: input.max.toString(), + step: input.step?.toString(), + oninput: "this.nextElementSibling.innerHTML = this.value", + }, + })} + <span>${input.initVal ?? ""}</span> + </div> `); - }); - } else if (this.type === "text") { - this.inputs.forEach((input) => { - if (input.type !== undefined && input.type !== "") { - if (input.type === "textarea") { - el.find(".inputs").append(` - <textarea - placeholder="${input.placeholder}" - class="${input.hidden ? "hidden" : ""}" - ${input.hidden ? "" : "required"} - ${input.disabled ? "disabled" : ""} - autocomplete="off" - >${input.initVal}</textarea> - `); - } else if (input.type === "checkbox") { - el.find(".inputs").append(` - <label class="checkbox"> - <input type="checkbox" checked=""> - <div>${input.label}</div> - </label> - `); - } else { - el.find(".inputs").append(` - <input - type="${input.type}" - value="${input.initVal}" - placeholder="${input.placeholder}" - class="${input.hidden ? "hidden" : ""}" - ${input.hidden ? "" : "required"} - ${input.disabled ? "disabled" : ""} - autocomplete="off" - > - `); + } else { + switch (input.type) { + case "text": + case "password": + case "email": + break; + + case "datetime-local": { + if (input.min !== undefined) { + attributes["min"] = dateFormat( + input.min, + "yyyy-MM-dd'T'HH:mm:ss" + ); + } + if (input.max !== undefined) { + attributes["max"] = dateFormat( + input.max, + "yyyy-MM-dd'T'HH:mm:ss" + ); + } + if (input.initVal !== undefined) { + attributes["value"] = dateFormat( + input.initVal, + "yyyy-MM-dd'T'HH:mm:ss" + ); + } + break; + } + case "date": { + if (input.min !== undefined) { + attributes["min"] = dateFormat(input.min, "yyyy-MM-dd"); + } + if (input.max !== undefined) { + attributes["max"] = dateFormat(input.max, "yyyy-MM-dd"); + } + if (input.initVal !== undefined) { + attributes["value"] = dateFormat(input.initVal, "yyyy-MM-dd"); + } + break; + } + case "number": { + attributes["min"] = input.min?.toString(); + attributes["max"] = input.max?.toString(); + break; } - } else { - el.find(".inputs").append(` - <input - type="text" - value="${input.initVal}" - placeholder="${input.placeholder}" - class="${input.hidden ? "hidden" : ""}" - ${input.hidden ? "" : "required"} - ${input.disabled ? "disabled" : ""} - autocomplete="off" - > - `); } - }); - } + inputs.append(buildTag({ tagname, classes, attributes })); + } + if (input.oninput !== undefined) { + ( + document.querySelector("#" + attributes["id"]) as HTMLElement + ).oninput = input.oninput; + } + }); + el.find(".inputs").removeClass("hidden"); } @@ -290,14 +408,21 @@ class SimpleModal { } } - const inputsWithCurrentValue = []; + type CommonInputWithCurrentValue = CommonInputType & { + currentValue: string | undefined; + }; + + const inputsWithCurrentValue: CommonInputWithCurrentValue[] = []; for (let i = 0; i < this.inputs.length; i++) { - inputsWithCurrentValue.push({ ...this.inputs[i], currentValue: vals[i] }); + inputsWithCurrentValue.push({ + ...(this.inputs[i] as CommonInputType), + currentValue: vals[i], + }); } if ( inputsWithCurrentValue - .filter((i) => !i.hidden) + .filter((i) => i.hidden !== true && i.optional !== true) .some((v) => v.currentValue === undefined || v.currentValue === "") ) { Notifications.add("Please fill in all fields", 0); @@ -494,7 +619,6 @@ async function reauthenticate( list.updateEmail = new SimpleModal({ id: "updateEmail", - type: "text", title: "Update email", inputs: [ { @@ -503,10 +627,12 @@ list.updateEmail = new SimpleModal({ initVal: "", }, { + type: "text", placeholder: "New email", initVal: "", }, { + type: "text", placeholder: "Confirm new email", initVal: "", }, @@ -565,7 +691,6 @@ list.updateEmail = new SimpleModal({ list.removeGoogleAuth = new SimpleModal({ id: "removeGoogleAuth", - type: "text", title: "Remove Google authentication", inputs: [ { @@ -620,7 +745,6 @@ list.removeGoogleAuth = new SimpleModal({ list.removeGithubAuth = new SimpleModal({ id: "removeGithubAuth", - type: "text", title: "Remove GitHub authentication", inputs: [ { @@ -675,7 +799,6 @@ list.removeGithubAuth = new SimpleModal({ list.updateName = new SimpleModal({ id: "updateName", - type: "text", title: "Update name", inputs: [ { @@ -741,7 +864,7 @@ list.updateName = new SimpleModal({ const snapshot = DB.getSnapshot(); if (!snapshot) return; if (!isUsingPasswordAuthentication()) { - (thisPopup.inputs[0] as Input).hidden = true; + (thisPopup.inputs[0] as PasswordInput).hidden = true; thisPopup.buttonText = "reauthenticate to update"; } if (snapshot.needsToChangeName === true) { @@ -753,7 +876,6 @@ list.updateName = new SimpleModal({ list.updatePassword = new SimpleModal({ id: "updatePassword", - type: "text", title: "Update password", inputs: [ { @@ -838,7 +960,6 @@ list.updatePassword = new SimpleModal({ list.addPasswordAuth = new SimpleModal({ id: "addPasswordAuth", - type: "text", title: "Add password authentication", inputs: [ { @@ -930,7 +1051,6 @@ list.addPasswordAuth = new SimpleModal({ list.deleteAccount = new SimpleModal({ id: "deleteAccount", - type: "text", title: "Delete account", inputs: [ { @@ -979,7 +1099,6 @@ list.deleteAccount = new SimpleModal({ list.resetAccount = new SimpleModal({ id: "resetAccount", - type: "text", title: "Reset account", inputs: [ { @@ -1030,7 +1149,6 @@ list.resetAccount = new SimpleModal({ list.optOutOfLeaderboards = new SimpleModal({ id: "optOutOfLeaderboards", - type: "text", title: "Opt out of leaderboards", inputs: [ { @@ -1077,7 +1195,6 @@ list.optOutOfLeaderboards = new SimpleModal({ list.clearTagPb = new SimpleModal({ id: "clearTagPb", - type: "text", title: "Clear tag PB", text: "Are you sure you want to clear this tags PB?", buttonText: "clear", @@ -1121,9 +1238,8 @@ list.clearTagPb = new SimpleModal({ list.applyCustomFont = new SimpleModal({ id: "applyCustomFont", - type: "text", title: "Custom font", - inputs: [{ placeholder: "Font name", initVal: "" }], + inputs: [{ type: "text", placeholder: "Font name", initVal: "" }], text: "Make sure you have the font installed on your computer before applying", buttonText: "apply", execFn: async (_thisPopup, fontName): Promise<ExecReturn> => { @@ -1138,7 +1254,6 @@ list.applyCustomFont = new SimpleModal({ list.resetPersonalBests = new SimpleModal({ id: "resetPersonalBests", - type: "text", title: "Reset personal bests", inputs: [ { @@ -1198,7 +1313,6 @@ list.resetPersonalBests = new SimpleModal({ list.resetSettings = new SimpleModal({ id: "resetSettings", - type: "text", title: "Reset settings", text: "Are you sure you want to reset all your settings?", buttonText: "reset", @@ -1214,7 +1328,6 @@ list.resetSettings = new SimpleModal({ list.revokeAllTokens = new SimpleModal({ id: "revokeAllTokens", - type: "text", title: "Revoke all tokens", inputs: [ { @@ -1255,7 +1368,7 @@ list.revokeAllTokens = new SimpleModal({ const snapshot = DB.getSnapshot(); if (!snapshot) return; if (!isUsingPasswordAuthentication()) { - (thisPopup.inputs[0] as Input).hidden = true; + (thisPopup.inputs[0] as PasswordInput).hidden = true; thisPopup.buttonText = "reauthenticate to revoke all tokens"; } }, @@ -1263,7 +1376,6 @@ list.revokeAllTokens = new SimpleModal({ list.unlinkDiscord = new SimpleModal({ id: "unlinkDiscord", - type: "text", title: "Unlink Discord", text: "Are you sure you want to unlink your Discord account?", buttonText: "unlink", @@ -1300,10 +1412,10 @@ list.unlinkDiscord = new SimpleModal({ list.generateApeKey = new SimpleModal({ id: "generateApeKey", - type: "text", title: "Generate new Ape key", inputs: [ { + type: "text", placeholder: "Name", initVal: "", }, @@ -1342,7 +1454,6 @@ list.generateApeKey = new SimpleModal({ list.viewApeKey = new SimpleModal({ id: "viewApeKey", - type: "text", title: "Ape key", inputs: [ { @@ -1366,7 +1477,7 @@ list.viewApeKey = new SimpleModal({ }; }, beforeInitFn: (_thisPopup): void => { - (_thisPopup.inputs[0] as Input).initVal = _thisPopup + (_thisPopup.inputs[0] as TextArea).initVal = _thisPopup .parameters[0] as string; }, beforeShowFn: (_thisPopup): void => { @@ -1382,7 +1493,6 @@ list.viewApeKey = new SimpleModal({ list.deleteApeKey = new SimpleModal({ id: "deleteApeKey", - type: "text", title: "Delete Ape key", text: "Are you sure?", buttonText: "delete", @@ -1408,10 +1518,10 @@ list.deleteApeKey = new SimpleModal({ list.editApeKey = new SimpleModal({ id: "editApeKey", - type: "text", title: "Edit Ape key", inputs: [ { + type: "text", placeholder: "name", initVal: "", }, @@ -1440,7 +1550,6 @@ list.editApeKey = new SimpleModal({ list.deleteCustomText = new SimpleModal({ id: "deleteCustomText", - type: "text", title: "Delete custom text", text: "Are you sure?", buttonText: "delete", @@ -1460,7 +1569,6 @@ list.deleteCustomText = new SimpleModal({ list.deleteCustomTextLong = new SimpleModal({ id: "deleteCustomTextLong", - type: "text", title: "Delete custom text", text: "Are you sure?", buttonText: "delete", @@ -1480,7 +1588,6 @@ list.deleteCustomTextLong = new SimpleModal({ list.resetProgressCustomTextLong = new SimpleModal({ id: "resetProgressCustomTextLong", - type: "text", title: "Reset progress for custom text", text: "Are you sure?", buttonText: "reset", @@ -1503,7 +1610,6 @@ list.resetProgressCustomTextLong = new SimpleModal({ list.updateCustomTheme = new SimpleModal({ id: "updateCustomTheme", - type: "text", title: "Update custom theme", inputs: [ { @@ -1513,7 +1619,7 @@ list.updateCustomTheme = new SimpleModal({ }, { type: "checkbox", - initVal: "false", + initVal: false, label: "Update custom theme to current colors", }, ], @@ -1578,13 +1684,12 @@ list.updateCustomTheme = new SimpleModal({ (t) => t._id === _thisPopup.parameters[0] ); if (!customTheme) return; - (_thisPopup.inputs[0] as Input).initVal = customTheme.name; + (_thisPopup.inputs[0] as TextInput).initVal = customTheme.name; }, }); list.deleteCustomTheme = new SimpleModal({ id: "deleteCustomTheme", - type: "text", title: "Delete custom theme", text: "Are you sure?", buttonText: "delete", @@ -1602,7 +1707,6 @@ list.deleteCustomTheme = new SimpleModal({ list.forgotPassword = new SimpleModal({ id: "forgotPassword", - type: "text", title: "Forgot password", inputs: [ { @@ -1634,11 +1738,97 @@ list.forgotPassword = new SimpleModal({ `.pageLogin .login input[name="current-email"]` ).val() as string; if (inputValue) { - (thisPopup.inputs[0] as Input).initVal = inputValue; + (thisPopup.inputs[0] as TextInput).initVal = inputValue; } }, }); +list.devGenerateData = new SimpleModal({ + id: "devGenerateData", + title: "Generate data", + showLabels: true, + inputs: [ + { + type: "text", + label: "username", + placeholder: "username", + oninput: (event): void => { + const target = event.target as HTMLInputElement; + const span = document.querySelector( + "#devGenerateData_1 + span" + ) as HTMLInputElement; + span.innerHTML = `if checked, user will be created with ${target.value}@example.com and password: password`; + return; + }, + }, + { + type: "checkbox", + label: "create user", + description: + "if checked, user will be created with {username}@example.com and password: password", + }, + { + type: "date", + label: "first test", + optional: true, + }, + { + type: "date", + label: "last test", + max: new Date(), + optional: true, + }, + { + type: "range", + label: "min tests per day", + initVal: 0, + min: 0, + max: 200, + step: 10, + }, + { + type: "range", + label: "max tests per day", + initVal: 50, + min: 0, + max: 200, + step: 10, + }, + ], + buttonText: "generate (might take a while)", + execFn: async ( + _thisPopup, + username, + createUser, + firstTestTimestamp, + lastTestTimestamp, + minTestsPerDay, + maxTestsPerDay + ): Promise<ExecReturn> => { + const request: Ape.Dev.GenerateData = { + username, + createUser: createUser === "true", + }; + if (firstTestTimestamp !== undefined && firstTestTimestamp.length > 0) + request.firstTestTimestamp = Date.parse(firstTestTimestamp); + if (lastTestTimestamp !== undefined && lastTestTimestamp.length > 0) + request.lastTestTimestamp = Date.parse(lastTestTimestamp); + if (minTestsPerDay !== undefined && minTestsPerDay.length > 0) + request.minTestsPerDay = Number.parseInt(minTestsPerDay); + if (maxTestsPerDay !== undefined && maxTestsPerDay.length > 0) + request.maxTestsPerDay = Number.parseInt(maxTestsPerDay); + + const result = await Ape.dev.generateData(request); + + return { + status: result.status === 200 ? 1 : -1, + message: result.message, + hideOptions: { + clearModalChain: true, + }, + }; + }, +}); export function showPopup( key: PopupKey, showParams = [] as string[], diff --git a/frontend/src/ts/ready.ts b/frontend/src/ts/ready.ts index fcbdca162..e373d44dc 100644 --- a/frontend/src/ts/ready.ts +++ b/frontend/src/ts/ready.ts @@ -8,7 +8,6 @@ import * as ConnectionState from "./states/connection"; import * as FunboxList from "./test/funbox/funbox-list"; //@ts-expect-error import Konami from "konami"; -import { envConfig } from "./constants/env-config"; import * as ServerConfiguration from "./ape/server-configuration"; $((): void => { @@ -69,8 +68,5 @@ $((): void => { void registration.unregister(); } }); - $("body").prepend( - `<a class='button configureAPI' href='${envConfig.backendUrl}/configure/' target='_blank' aria-label="Configure API" data-balloon-pos="right"><i class="fas fa-fw fa-server"></i></a>` - ); } }); diff --git a/frontend/src/ts/utils/async-modules.ts b/frontend/src/ts/utils/async-modules.ts index 04894d70a..fa24ff832 100644 --- a/frontend/src/ts/utils/async-modules.ts +++ b/frontend/src/ts/utils/async-modules.ts @@ -30,3 +30,31 @@ export async function getCommandline(): Promise< throw e; } } + +Skeleton.save("devOptionsModal"); + +export async function getDevOptionsModal(): Promise< + typeof import("../modals/dev-options.js") +> { + try { + Loader.show(); + const module = await import("../modals/dev-options.js"); + Loader.hide(); + return module; + } catch (e) { + Loader.hide(); + if ( + e instanceof Error && + e.message.includes("Failed to fetch dynamically imported module") + ) { + Notifications.add( + "Failed to load dev options module: could not fetch", + -1 + ); + } else { + const msg = createErrorMessage(e, "Failed to load dev options module"); + Notifications.add(msg, -1); + } + throw e; + } +} diff --git a/frontend/src/ts/utils/tag-builder.ts b/frontend/src/ts/utils/tag-builder.ts new file mode 100644 index 000000000..e539a4512 --- /dev/null +++ b/frontend/src/ts/utils/tag-builder.ts @@ -0,0 +1,31 @@ +export type Attributes = Record<string, string | true | undefined>; +type TagOptions = { + tagname: string; + classes?: string[]; + attributes?: Attributes; + innerHTML?: string; +}; + +export function buildTag({ + tagname, + classes, + attributes, + innerHTML, +}: TagOptions): string { + let html = `<${tagname}`; + if (classes !== undefined) html += ` class="${classes.join(" ")}"`; + + if (attributes !== undefined) { + html += + " " + + Object.entries(attributes) + .filter((it) => it[1] !== undefined) + .sort((a, b) => a[0].localeCompare(b[0])) + .map((it) => (it[1] === true ? `${it[0]}` : `${it[0]}="${it[1]}"`)) + .join(" "); + } + + if (innerHTML !== undefined) html += `>${innerHTML}</${tagname}>`; + else html += " />"; + return html; +} |