From 86b3532b56f6c8ceba985d743b16e2af6fb460fe Mon Sep 17 00:00:00 2001 From: Kevin Berry <41717340+51ngul4r1ty@users.noreply.github.com> Date: Tue, 30 Aug 2022 09:55:36 -0400 Subject: [PATCH 01/13] howto for generating components from affinity svg --- docs/DEV_HOWTO.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs/DEV_HOWTO.md b/docs/DEV_HOWTO.md index 33c79927..c88ad514 100644 --- a/docs/DEV_HOWTO.md +++ b/docs/DEV_HOWTO.md @@ -89,3 +89,42 @@ export default connect( mapDispatchToProps )(withTranslation()(App)); ``` + +Generating React Components from SVG Files +========================================== + +This app relies on fast rendering of images so they're all in-lined. This should also make it easier for code-splitting, +lazy-loading, etc. + +Steps to Generate SVG Files (if using Affinity Designer) +-------------------------------------------------------- + +1. The `atoll-shared` repo contains all the SVG assets and generated components. + Use terminal and `cd atoll-shared` to execute all commands below in this folder. +2. The original SVG file should be stored in the `/assets` folder. +3. Make sure to name components using the convention: `{lowercase-component-name}-icon.svg`, + for example `menu-caret-down-icon.svg` +4. Use `npm run gen:react-svg -- menu-caret-down-icon` to generate the basic React component `MenuCareDownIcon.tsx`. +5. The file will be placed in the `/components/atoms/icons` folder. +6. Affinity places additional SVG elements that aren't needed so you should remove them: + - remove the line with `xmlnsSerif=` + - remove the attribute `serifId="{component-title}"` (for example, `serifId="Menu Caret"`) + - change `const classNameToUse = buildClassName(props.className);` + to `const classNameToUse = buildClassName(strokeClass, props.className);` + - add `fill="none"` and `className={classNameToUse}` attributes to the top of the `svg` element. + - remove `id` attribute from the `g` element and replace it with `className={fillClass}`, + for example, ` Date: Tue, 30 Aug 2022 09:57:18 -0400 Subject: [PATCH 02/13] svg preview component settings --- .vscode/extensions.json | 1 + atoll-core-main.code-workspace | 3 ++- package.json | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 8a4b26ff..bf7fcfd6 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -7,6 +7,7 @@ "andrewleedham.vscode-css-modules", "esbenp.prettier-vscode", "gruntfuggly.todo-tree", + "jock.svg", "msjsdiag.debugger-for-chrome", "orta.vscode-jest", "ryanluker.vscode-coverage-gutters", diff --git a/atoll-core-main.code-workspace b/atoll-core-main.code-workspace index 37f386f0..b2b7bb37 100644 --- a/atoll-core-main.code-workspace +++ b/atoll-core-main.code-workspace @@ -26,6 +26,7 @@ "deno.import_intellisense_origins": { "https://deno.land": true }, - "jest.jestCommandLine": "node_modules/.bin/jest" + "jest.jestCommandLine": "node_modules/.bin/jest", + "svg.preview.background": "dark-transparent" } } diff --git a/package.json b/package.json index 40ee9963..4b909041 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "atoll", - "version": "0.61.0", + "version": "0.62.0", "author": { "name": "Kevin Berry", "email": "41717340+51ngul4r1ty@users.noreply.github.com" @@ -59,7 +59,7 @@ "check:prereqs": "ts-node ./scripts/check-prereqs.ts" }, "dependencies": { - "@atoll/shared": "0.61.0", + "@atoll/shared": "file:.yalc/@atoll/shared", "@flopflip/memory-adapter": "1.6.0", "@flopflip/react-broadcast": "10.1.11", "axios": "0.26.1", From 1ed218804d5d0177811c81daf4f289f734882299 Mon Sep 17 00:00:00 2001 From: Kevin Berry <41717340+51ngul4r1ty@users.noreply.github.com> Date: Tue, 30 Aug 2022 09:57:28 -0400 Subject: [PATCH 03/13] fix HOWTO link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f351fa5e..27b6cf63 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ section. Everyone contributing to this repo should read this document before doing anything: [IMPORTANT.md](docs/IMPORTANT.md) -For specialized instructions (to save you time trying to do various things): [HOWTO.md](docs/HOWTO.md) +For specialized instructions (to save you time trying to do various things): [DEV_HOWTO.md](docs/DEV_HOWTO.md) Many code standards and conventions can be picked up from existing patterns in the code but it is advisable to use this resource as well: [CODE_STANDARDS.md](docs/CODE_STANDARDS.md) From 29a7ed28138e79607f8baee301c7d90e32da5806 Mon Sep 17 00:00:00 2001 From: Kevin Berry <41717340+51ngul4r1ty@users.noreply.github.com> Date: Sat, 10 Sep 2022 20:56:08 -0400 Subject: [PATCH 04/13] provide endpoint to update user prefs --- src/server/api/handlers/backlogItemParts.ts | 1 + src/server/api/handlers/sprints.ts | 10 +-- .../updaters/userPreferencesUpdater.ts | 58 +++++++++++++++++ src/server/api/handlers/userPreferences.ts | 54 ++++++++++++++- src/server/api/routes.ts | 4 +- .../api/utils/__tests__/patcher.test.ts | 17 +++++ src/server/api/utils/patcher.ts | 65 +++++++++++++++++-- 7 files changed, 192 insertions(+), 17 deletions(-) create mode 100644 src/server/api/handlers/updaters/userPreferencesUpdater.ts diff --git a/src/server/api/handlers/backlogItemParts.ts b/src/server/api/handlers/backlogItemParts.ts index 5beab2ba..c10206d6 100644 --- a/src/server/api/handlers/backlogItemParts.ts +++ b/src/server/api/handlers/backlogItemParts.ts @@ -191,6 +191,7 @@ const handleResponseWithUpdatedStatsAndCommit = async ( } if (transaction) { await transaction.commit(); + // TODO: Is this supposed to set the transaction object in handlerContext to null? Not sure it does that. transaction = null; } const extra = sprintStats ? { sprintStats, backlogItem: newApiBacklogItem } : { backlogItem: newApiBacklogItem }; diff --git a/src/server/api/handlers/sprints.ts b/src/server/api/handlers/sprints.ts index 998381e0..6574306d 100644 --- a/src/server/api/handlers/sprints.ts +++ b/src/server/api/handlers/sprints.ts @@ -4,15 +4,7 @@ import { StatusCodes } from "http-status-codes"; import { Op } from "sequelize"; // libraries -import { - ApiSprint, - ApiSprintBacklogItem, - DateOnly, - determineSprintStatus, - isoDateStringToDate, - logger, - SprintStatus -} from "@atoll/shared"; +import { ApiSprint, DateOnly, determineSprintStatus, logger, SprintStatus } from "@atoll/shared"; // data access import { sequelize } from "../../dataaccess/connection"; diff --git a/src/server/api/handlers/updaters/userPreferencesUpdater.ts b/src/server/api/handlers/updaters/userPreferencesUpdater.ts new file mode 100644 index 00000000..99642985 --- /dev/null +++ b/src/server/api/handlers/updaters/userPreferencesUpdater.ts @@ -0,0 +1,58 @@ +// libraries +import { ApiUserSettings } from "@atoll/shared"; + +// data access +import { UserSettingsDataModel } from "../../../dataaccess/models/UserSettingsDataModel"; + +// consts/enums +import { + buildNotImplementedResponse, + buildResponseFromCatchError, + RestApiErrorResult, + RestApiItemResult +} from "../../utils/responseBuilder"; + +// interfaces/types +import { HandlerContext } from "../utils/handlerContext"; + +// utils +import { mapDbToApiUserSettings } from "../../../dataaccess/mappers/dataAccessToApiMappers"; +import { getPatchedItem } from "../../utils/patcher"; + +export type UserPreferencesResult = RestApiErrorResult | UserPreferencesItemResult; + +export type UserPreferencesItemResult = RestApiItemResult; + +export const patchUserPreferences = async ( + handlerContext: HandlerContext, + reqBody: any, + originalUserSettings: ApiUserSettings, + dbUserSettings: UserSettingsDataModel, + userId: string +) => { + try { + let newDataItem: ApiUserSettings; + // TODO: Maybe this validation check should occur outside the "patcher"?? + if (userId !== "--self--") { + return buildNotImplementedResponse( + "This endpoint is intended as an admin endpoint, so a typical user would not be able to use it." + ); + } else { + if (reqBody.settings) { + newDataItem = getPatchedItem( + originalUserSettings, + { settings: reqBody.settings }, + { preserveNestedFields: true, allowExtraFields: true } + ); + const transaction = handlerContext.transactionContext.transaction; + await dbUserSettings.update(newDataItem, { + transaction + }); + return mapDbToApiUserSettings(dbUserSettings); + } + return originalUserSettings; + } + } catch (error) { + return buildResponseFromCatchError(error); + } +}; diff --git a/src/server/api/handlers/userPreferences.ts b/src/server/api/handlers/userPreferences.ts index 6ace3cef..0442a1dd 100644 --- a/src/server/api/handlers/userPreferences.ts +++ b/src/server/api/handlers/userPreferences.ts @@ -7,13 +7,25 @@ import * as findPackageJson from "find-package-json"; import { timeNow } from "@atoll/shared"; // interfaces/types -import type { RestApiErrorResult } from "../utils/responseBuilder"; +import { buildNotFoundResponse, buildResponseWithItem, RestApiErrorResult } from "../utils/responseBuilder"; // utils import { getLoggedInAppUserId } from "../utils/authUtils"; import { getUserPreferences } from "./fetchers/userPreferencesFetcher"; +import { mapDbToApiUserSettings } from "../../dataaccess/mappers/dataAccessToApiMappers"; +import { UserSettingsDataModel } from "../../dataaccess/models/UserSettingsDataModel"; +import { patchUserPreferences } from "./updaters/userPreferencesUpdater"; +import { + beginSerializableTransaction, + finish, + handleFailedValidationResponse, + handleSuccessResponse, + handleUnexpectedErrorResponse, + start +} from "./utils/handlerContext"; +import { respondWithObj } from "api/utils/responder"; -export const userPreferencesHandler = async function (req: Request, res: Response) { +export const userPreferencesGetHandler = async function (req: Request, res: Response) { const packageJson = findPackageJson(__dirname); const packageJsonContents = packageJson.next().value; const version = packageJsonContents.version; @@ -31,3 +43,41 @@ export const userPreferencesHandler = async function (req: Request, res: Respons console.log(`Unable to fetch user preferences: ${errorResult.message}`); } }; + +export const userPreferencesPatchHandler = async function (req: Request, res: Response) { + const handlerContext = start("backlogItemPartPatchHandler", res); + try { + const userId = req.params.userId || ""; + if (userId !== "--self--") { + await handleFailedValidationResponse( + handlerContext, + "This endpoint is intended as an admin endpoint, so a typical user would not be able to use it." + ); + } else { + await beginSerializableTransaction(handlerContext); + const appuserId = getLoggedInAppUserId(req); + let dbUserSettings: any = await UserSettingsDataModel.findOne({ + where: { appuserId } + }); + if (dbUserSettings) { + const originalApiUserSettings = mapDbToApiUserSettings(dbUserSettings); + const newDataItem = await patchUserPreferences( + handlerContext, + req.body, + originalApiUserSettings, + dbUserSettings, + userId + ); + + const extra = undefined; + const meta = undefined; + await handleSuccessResponse(handlerContext, buildResponseWithItem(newDataItem, extra, meta)); + } else { + return buildNotFoundResponse("Unable to patch user settings- object was not found for this user"); + } + finish(handlerContext); + } + } catch (err) { + await handleUnexpectedErrorResponse(handlerContext, err); + } +}; diff --git a/src/server/api/routes.ts b/src/server/api/routes.ts index 5b3ecf92..8bacf278 100644 --- a/src/server/api/routes.ts +++ b/src/server/api/routes.ts @@ -42,7 +42,7 @@ import { import { productBacklogItemsGetHandler, productBacklogItemGetHandler } from "./handlers/productBacklogItems"; import { featureTogglesHandler } from "./handlers/featureToggles"; import { rootHandler } from "./handlers/root"; -import { userPreferencesHandler } from "./handlers/userPreferences"; +import { userPreferencesGetHandler, userPreferencesPatchHandler } from "./handlers/userPreferences"; import { loginPostHandler, refreshTokenPostHandler } from "./handlers/auth"; import { sprintBacklogItemPartGetHandler, sprintBacklogItemPartsPostHandler } from "./handlers/sprintBacklogItemParts"; import { planViewBffGetHandler } from "./handlers/views/planViewBff"; @@ -73,7 +73,7 @@ router.options("/*", (req, res, next) => { setupNoAuthRoutes(router, "/", { get: rootHandler }); -setupRoutes(router, "/users/:userId/preferences", { get: userPreferencesHandler }); +setupRoutes(router, "/users/:userId/preferences", { get: userPreferencesGetHandler, patch: userPreferencesPatchHandler }); setupRoutes(router, "/users/:userId/feature-toggles", { get: featureTogglesHandler }); diff --git a/src/server/api/utils/__tests__/patcher.test.ts b/src/server/api/utils/__tests__/patcher.test.ts index 6f85d5ed..4f4b514a 100644 --- a/src/server/api/utils/__tests__/patcher.test.ts +++ b/src/server/api/utils/__tests__/patcher.test.ts @@ -278,5 +278,22 @@ describe("Patcher", () => { missingField: "keep-this" }); }); + it("should preserve nested fields when preserveNestedFields option is set", () => { + // arrange + const obj = { + settings: { selectedProject: "69a9288264964568beb5dd243dc29008", detectBrowserDarkMode: true } + }; + const fields = { + settings: { selectedProject: "8220723fed61402abb8ee5170be741cb" } + }; + + // act + const actual = getPatchedItem(obj, fields, { preserveNestedFields: true }); + + // assert + expect(actual).toStrictEqual({ + settings: { selectedProject: "8220723fed61402abb8ee5170be741cb", detectBrowserDarkMode: true } + }); + }); }); }); diff --git a/src/server/api/utils/patcher.ts b/src/server/api/utils/patcher.ts index 46360fb0..2a1d8b77 100644 --- a/src/server/api/utils/patcher.ts +++ b/src/server/api/utils/patcher.ts @@ -17,7 +17,15 @@ export const validateBaseKeys = (targetNode: any, sourceNode: any): PatchValidat }; }; -export const validatePatchObjects = (targetNode: any, sourceNode: any): PatchValidationResult => { +export type ValidatePatchObjectsOptions = { + allowExtraFields: boolean; +}; + +export const validatePatchObjects = ( + targetNode: any, + sourceNode: any, + options: ValidatePatchObjectsOptions = { allowExtraFields: false } +): PatchValidationResult => { const validationResult = validateBaseKeys(targetNode, sourceNode); const sourceKeys = Object.keys(sourceNode || {}); sourceKeys.forEach((key) => { @@ -33,7 +41,7 @@ export const validatePatchObjects = (targetNode: any, sourceNode: any): PatchVal } }); return { - valid: validationResult.extraFields.length === 0, + valid: validationResult.extraFields.length === 0 || options.allowExtraFields, extraFields: validationResult.extraFields }; }; @@ -53,10 +61,59 @@ export const getInvalidPatchMessage = (obj: any, fields: any) => { return null; }; -export const getPatchedItem = (obj: T, fields: any): T => { - const validationResult = validatePatchObjects(obj, fields); +export type GetPatchedItemOptions = { + preserveNestedFields: boolean; + allowExtraFields: boolean; +}; + +const getPatchedItemInternal = (obj: T, fields: any, basePropertyPath: string, options: GetPatchedItemOptions): T => { + if (options.allowExtraFields && !options.preserveNestedFields) { + throw new Error("getPatchedItem may not work properly when allowExtraFields is used without preserveNestedFields"); + } + const validationResult = validatePatchObjects(obj, fields, { allowExtraFields: options.allowExtraFields }); if (!validationResult.valid) { throw new Error(getValidationFailureMessage(validationResult)); } + if (options.preserveNestedFields) { + const result: any = {}; + const sourceFieldSet = new Set(); + Object.keys(obj).forEach((fieldName) => { + sourceFieldSet.add(fieldName); + const propertyPath = basePropertyPath ? `${basePropertyPath}.${fieldName}` : fieldName; + const sourceFieldValue = obj[fieldName]; + if (!fields.hasOwnProperty(fieldName)) { + result[fieldName] = sourceFieldValue; + } else if (typeof sourceFieldValue === "object") { + const targetFieldObjectValue = fields[fieldName]; + if (targetFieldObjectValue === null) { + result[fieldName] = null; + } else if (typeof targetFieldObjectValue !== "object") { + throw new Error( + `Unable to patch object- nested target property "${propertyPath}"` + + " is not an object, but source property is" + ); + } else { + result[fieldName] = getPatchedItemInternal(sourceFieldValue, targetFieldObjectValue, propertyPath, options); + } + } else { + const targetFieldValue = fields[fieldName]; + result[fieldName] = targetFieldValue; + } + }); + Object.keys(fields).forEach((fieldName) => { + if (!sourceFieldSet.has(fieldName)) { + result[fieldName] = fields[fieldName]; + } + }); + return result; + } return { ...obj, ...fields }; }; + +export const getPatchedItem = ( + obj: T, + fields: any, + options: GetPatchedItemOptions = { preserveNestedFields: false, allowExtraFields: false } +): T => { + return getPatchedItemInternal(obj, fields, "", options); +}; From 9047a00255f8cba79a04cb214ef206accefda423 Mon Sep 17 00:00:00 2001 From: Kevin Berry <41717340+51ngul4r1ty@users.noreply.github.com> Date: Sat, 10 Sep 2022 23:58:26 -0400 Subject: [PATCH 05/13] switch projects --- src/server/api/handlers/userPreferences.ts | 2 +- src/server/api/handlers/utils/handlerContext.ts | 3 +++ src/server/api/utils/routerHelper.ts | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/server/api/handlers/userPreferences.ts b/src/server/api/handlers/userPreferences.ts index 0442a1dd..17cc4c6d 100644 --- a/src/server/api/handlers/userPreferences.ts +++ b/src/server/api/handlers/userPreferences.ts @@ -45,7 +45,7 @@ export const userPreferencesGetHandler = async function (req: Request, res: Resp }; export const userPreferencesPatchHandler = async function (req: Request, res: Response) { - const handlerContext = start("backlogItemPartPatchHandler", res); + const handlerContext = start("userPreferencesPatchHandler", res); try { const userId = req.params.userId || ""; if (userId !== "--self--") { diff --git a/src/server/api/handlers/utils/handlerContext.ts b/src/server/api/handlers/utils/handlerContext.ts index ec80b4c1..e84c95c7 100644 --- a/src/server/api/handlers/utils/handlerContext.ts +++ b/src/server/api/handlers/utils/handlerContext.ts @@ -129,6 +129,9 @@ export const rollbackWithMessageAndStatus = async (handlerContext: HandlerContex const handleTransactionRollback = async (handlerContext: HandlerContext, logContext: LoggingContext) => { const context = handlerContext.transactionContext; + if (!context) { + return; + } if (context.transaction && !context.rolledBack) { logger.info("rolling back transaction", [handlerContext.functionTag], logContext); try { diff --git a/src/server/api/utils/routerHelper.ts b/src/server/api/utils/routerHelper.ts index 1e934fda..c21b021f 100644 --- a/src/server/api/utils/routerHelper.ts +++ b/src/server/api/utils/routerHelper.ts @@ -46,10 +46,10 @@ export const setupRoutes = (router: Router, path: string, handlers: RouteHandler export const setupNotFoundRoutes = (router: Router) => { router.all("*", (req, res, next) => { - res.json({ + res.status(StatusCodes.NOT_FOUND).send({ status: 404, message: `Cannot ${req.method} ${req.originalUrl}` - }).status(StatusCodes.NOT_FOUND); + }); }); }; From 41625eaf56adb64c68ff0aba6f4c05ccb58620c6 Mon Sep 17 00:00:00 2001 From: Kevin Berry <41717340+51ngul4r1ty@users.noreply.github.com> Date: Sun, 11 Sep 2022 17:49:28 -0400 Subject: [PATCH 06/13] update docs to show new middleware code sample --- docs/CODE_STANDARDS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/CODE_STANDARDS.md b/docs/CODE_STANDARDS.md index 54ac4def..52c1ba5a 100644 --- a/docs/CODE_STANDARDS.md +++ b/docs/CODE_STANDARDS.md @@ -56,7 +56,7 @@ Middleware should have `next(action);` as the first line to ensure that state is Ensure that you're using the correct types like this: ``` -export const apiOrchestrationMiddleware = (store: StoreTyped) => (next) => (action: Action) => { +export const apiOrchestrationMiddleware: Middleware<{}, StateTree> = (store: StoreTyped) => (next) => (action: Action) => { ``` State retrieval is very common in middleware so it should be done at the start of the middleware like below. From 9842c7910c25017fc4eae6532daff363f7615cf7 Mon Sep 17 00:00:00 2001 From: Kevin Berry <41717340+51ngul4r1ty@users.noreply.github.com> Date: Mon, 12 Sep 2022 22:21:57 -0400 Subject: [PATCH 07/13] use route constants --- src/common/routeBuilder.tsx | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/common/routeBuilder.tsx b/src/common/routeBuilder.tsx index 34f151b2..fa35fb2a 100644 --- a/src/common/routeBuilder.tsx +++ b/src/common/routeBuilder.tsx @@ -14,23 +14,25 @@ import { PlanViewContainer, ReviewViewContainer, SprintViewContainer, - layouts + layouts, + HOME_VIEW_ROUTE, + PLAN_VIEW_ROUTE, + SPRINT_VIEW_ROUTE, + REVIEW_VIEW_ROUTE, + DEBUG_PBI_VIEW_ROUTE, + BACKLOGITEM_VIEW_ROUTE } from "@atoll/shared"; const appRoutes = ( - - - - - - + + + + + + From 48d0fb1c99818c5412881f410dce90f46ae8ed55 Mon Sep 17 00:00:00 2001 From: Kevin Berry <41717340+51ngul4r1ty@users.noreply.github.com> Date: Tue, 13 Sep 2022 09:10:46 -0400 Subject: [PATCH 08/13] allow add first sprint (partial) --- src/server/api/handlers/views/planViewBff.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/server/api/handlers/views/planViewBff.ts b/src/server/api/handlers/views/planViewBff.ts index e6d73c54..eb029cff 100644 --- a/src/server/api/handlers/views/planViewBff.ts +++ b/src/server/api/handlers/views/planViewBff.ts @@ -31,14 +31,14 @@ export const planViewBffGetHandler = async (req: Request, res: Response) => { const userPreferencesResult = await getUserPreferences("--self--", () => getLoggedInAppUserId(req)); const selectedProjectId = (userPreferencesResult as UserPreferencesItemResult).data.item.settings.selectedProject; - const archived = "N"; const [projectResult, backlogItemsResult, sprintsResult] = await Promise.all([ fetchProject(selectedProjectId), fetchBacklogItems(selectedProjectId), - fetchSprints(selectedProjectId, archived) + fetchSprints(selectedProjectId) ]); const sprintsSuccessResult = sprintsResult as RestApiCollectionResult; let sprints = sprintsSuccessResult.data ? sprintsSuccessResult.data?.items : []; + const archivedSprints = sprints.filter((sprint) => sprint.archived === true); let sprintBacklogItemsResult: SprintBacklogItemsResult | RestApiErrorResult; let sprintBacklogItemsStatus = StatusCodes.OK; let sprintBacklogItemsMessage = ""; @@ -76,6 +76,10 @@ export const planViewBffGetHandler = async (req: Request, res: Response) => { isRestApiCollectionResult(sprintBacklogItemsResult) && isRestApiItemResult(projectResult) ) { + const projectStats = { + totalSprintCount: sprints.length, + archivedSprintCount: archivedSprints.length + }; res.json( buildResponseWithData({ backlogItems: backlogItemsResult.data?.items, @@ -83,7 +87,8 @@ export const planViewBffGetHandler = async (req: Request, res: Response) => { sprintBacklogItems: sprintBacklogItemsResult?.data?.items || [], userPreferences: (userPreferencesResult as UserPreferencesItemResult).data?.item, expandedSprintId: expandedSprintId ?? null, - project: projectResult.data?.item + project: projectResult.data?.item, + projectStats }) ); } else { From d856256872686aad2f0d0cfb8c6a7ed788b00272 Mon Sep 17 00:00:00 2001 From: Kevin Berry <41717340+51ngul4r1ty@users.noreply.github.com> Date: Wed, 14 Sep 2022 08:42:08 -0400 Subject: [PATCH 09/13] fix issue adding first item to new project --- src/server/api/handlers/backlogItems.ts | 59 +++++++++++-------- .../deleters/productBacklogItemDeleter.ts | 3 + .../handlers/fetchers/backlogItemFetcher.ts | 5 +- .../inserters/productBacklogItemInserter.ts | 6 +- 4 files changed, 45 insertions(+), 28 deletions(-) diff --git a/src/server/api/handlers/backlogItems.ts b/src/server/api/handlers/backlogItems.ts index 3960ac43..a52ae7ff 100644 --- a/src/server/api/handlers/backlogItems.ts +++ b/src/server/api/handlers/backlogItems.ts @@ -194,51 +194,58 @@ export const backlogItemsDeleteHandler = async (req: Request, res: Response) => } }; -const getNewCounterValue = async (projectId: string, backlogItemType: string) => { +/** + * Get a new counter value - caller is responsible for managing the transaction. + */ +const getNewCounterValue = async (projectId: string, backlogItemType: string, transaction: Transaction) => { let result: string; + let stage = "init"; const entitySubtype = backlogItemType === "story" ? "story" : "issue"; - let transaction: Transaction; try { - let rolledBack = false; - transaction = await sequelize.transaction({ isolationLevel: Transaction.ISOLATION_LEVELS.SERIALIZABLE }); - let projectSettingsItem: any = await ProjectSettingsDataModel.findOne({ - where: { projectId: projectId }, - transaction - }); + const projectSettingsFindOptions: FindOptions = { + where: { projectId: projectId } + }; + if (transaction) { + projectSettingsFindOptions.transaction = transaction; + } + stage = "retrieve project settings"; + let projectSettingsItem: any = await ProjectSettingsDataModel.findOne(projectSettingsFindOptions); if (!projectSettingsItem) { + stage = "retrieve global project settings"; projectSettingsItem = await ProjectSettingsDataModel.findOne({ where: { projectId: null }, transaction }); } if (projectSettingsItem) { + stage = "process project settings item"; const projectSettingsItemTyped = mapDbToApiProjectSettings(projectSettingsItem); const counterSettings = projectSettingsItemTyped.settings.counters[entitySubtype]; const entityNumberPrefix = counterSettings.prefix; const entityNumberSuffix = counterSettings.suffix; - const counterItem: any = await CounterDataModel.findOne({ - where: { entity: "project", entityId: projectId, entitySubtype }, - transaction - }); + const counterFindOptions: FindOptions = { + where: { entity: "project", entityId: projectId, entitySubtype } + }; + if (transaction) { + counterFindOptions.transaction = transaction; + } + stage = "retrieve counter data for project"; + const counterItem: any = await CounterDataModel.findOne(counterFindOptions); if (counterItem) { + stage = "process counter item"; const counterItemTyped = mapDbToApiCounter(counterItem); counterItemTyped.lastNumber++; let counterValue = entityNumberPrefix || ""; counterValue += formatNumber(counterItemTyped.lastNumber, counterSettings.totalFixedLength); counterValue += entityNumberSuffix || ""; counterItemTyped.lastCounterValue = counterValue; + stage = "store new counter value"; await counterItem.update(counterItemTyped); result = counterItem.lastCounterValue; } } - if (!rolledBack) { - await transaction.commit(); - } } catch (err) { - if (transaction) { - await transaction.rollback(); - } - throw new Error(`Unable to get new ID value, ${err}`); + throw new Error(`Unable to get new ID value (stage "${stage}"), ${err}`); } if (!result) { throw new Error("Unable to get new ID value - could not retrieve counter item"); @@ -247,17 +254,17 @@ const getNewCounterValue = async (projectId: string, backlogItemType: string) => }; export const backlogItemsPostHandler = async (req: Request, res: Response) => { - const bodyWithId = { ...addIdToBody(req.body) }; - if (!bodyWithId.friendlyId) { - const friendlyIdValue = await getNewCounterValue(req.body.projectId, req.body.type); - bodyWithId.friendlyId = friendlyIdValue; - } - const prevBacklogItemId = bodyWithId.prevBacklogItemId; - delete bodyWithId.prevBacklogItemId; let transaction: Transaction; try { let rolledBack = false; transaction = await sequelize.transaction({ isolationLevel: Transaction.ISOLATION_LEVELS.SERIALIZABLE }); + const bodyWithId = { ...addIdToBody(req.body) }; + if (!bodyWithId.friendlyId) { + const friendlyIdValue = await getNewCounterValue(req.body.projectId, req.body.type, transaction); + bodyWithId.friendlyId = friendlyIdValue; + } + const prevBacklogItemId = bodyWithId.prevBacklogItemId; + delete bodyWithId.prevBacklogItemId; await sequelize.query('SET CONSTRAINTS "productbacklogitem_backlogitemId_fkey" DEFERRED;', { transaction }); await sequelize.query('SET CONSTRAINTS "productbacklogitem_nextbacklogitemId_fkey" DEFERRED;', { transaction }); const updateBacklogItemPartResult = getUpdatedBacklogItemWhenStatusChanges(null, bodyWithId); diff --git a/src/server/api/handlers/deleters/productBacklogItemDeleter.ts b/src/server/api/handlers/deleters/productBacklogItemDeleter.ts index 01d16c33..5d2edef0 100644 --- a/src/server/api/handlers/deleters/productBacklogItemDeleter.ts +++ b/src/server/api/handlers/deleters/productBacklogItemDeleter.ts @@ -11,6 +11,9 @@ import { buildResponseFromCatchError, buildResponseWithItem } from "../../utils/ import { mapDbToApiProductBacklogItem } from "../../../dataaccess/mappers/dataAccessToApiMappers"; export const removeFromProductBacklog = async (backlogitemId: string, transaction?: Transaction) => { + if (!backlogitemId) { + throw new Error("Unable to remove product backlog item entries using a null/undefined ID"); + } try { const findItemOptions: FindOptions = buildOptionsWithTransaction({ where: { backlogitemId } }, transaction); const item = await ProductBacklogItemDataModel.findOne(findItemOptions); diff --git a/src/server/api/handlers/fetchers/backlogItemFetcher.ts b/src/server/api/handlers/fetchers/backlogItemFetcher.ts index 356956c6..55f0ea08 100644 --- a/src/server/api/handlers/fetchers/backlogItemFetcher.ts +++ b/src/server/api/handlers/fetchers/backlogItemFetcher.ts @@ -105,7 +105,10 @@ export const fetchBacklogItemsByDisplayId = async ( } }; -export const fetchBacklogItems = async (projectId: string | null): Promise => { +export const fetchBacklogItems = async (projectId: string): Promise => { + if (!projectId) { + throw new Error("Unable to retrieve backlog items without specifying a projectId"); + } try { const params = { projectId }; const options = buildOptionsFromParams(params); diff --git a/src/server/api/handlers/inserters/productBacklogItemInserter.ts b/src/server/api/handlers/inserters/productBacklogItemInserter.ts index 816a2af2..4a3ced9c 100644 --- a/src/server/api/handlers/inserters/productBacklogItemInserter.ts +++ b/src/server/api/handlers/inserters/productBacklogItemInserter.ts @@ -17,10 +17,14 @@ export const productBacklogItemFirstItemInserter = async ( bodyWithId, transaction: Transaction ): Promise => { + const projectId = bodyWithId.projectId; + if (!projectId) { + throw new Error("Unable to update the product backlog without a projectId"); + } // inserting first item means one of 2 scenarios: // 1) no items in database yet (add prev = null, next = this new item + add prev = new item, next = null) // 2) insert before first item (update item's prev to this item, add prev = null, next = this new item) - const firstItems = await ProductBacklogItemDataModel.findAll({ where: { backlogitemId: null }, transaction }); + const firstItems = await ProductBacklogItemDataModel.findAll({ where: { backlogitemId: null, projectId }, transaction }); if (!firstItems.length) { // scenario 1, insert head and tail await ProductBacklogItemDataModel.create( From fc7426cedcf649405a3af808eceab016ab9963e5 Mon Sep 17 00:00:00 2001 From: Kevin Berry <41717340+51ngul4r1ty@users.noreply.github.com> Date: Wed, 14 Sep 2022 08:42:43 -0400 Subject: [PATCH 10/13] make routes easier to read --- src/server/api/routes.ts | 145 ++++++++++++++++++++---------------- src/server/resourceNames.ts | 1 + 2 files changed, 83 insertions(+), 63 deletions(-) diff --git a/src/server/api/routes.ts b/src/server/api/routes.ts index 8bacf278..40221846 100644 --- a/src/server/api/routes.ts +++ b/src/server/api/routes.ts @@ -14,7 +14,8 @@ import { SPRINT_BACKLOG_ITEM_PART_RESOURCE_NAME, SPRINT_BACKLOG_PARENT_RESOURCE_NAME, SPRINT_BACKLOG_PART_CHILD_RESOURCE_NAME, - SPRINT_RESOURCE_NAME + SPRINT_RESOURCE_NAME, + USER_RESOURCE_NAME } from "../resourceNames"; // utils @@ -65,6 +66,10 @@ import { backlogItemPartGetHandler, backlogItemPartPatchHandler } from "./handle export const router = express.Router(); +// this is a local mapping to make the URLs easier to read +const USERS = USER_RESOURCE_NAME; +const PROJECTS = PROJECT_RESOURCE_NAME; + router.options("/*", (req, res, next) => { res.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); res.set("Access-Control-Allow-Origin", "*"); @@ -73,89 +78,103 @@ router.options("/*", (req, res, next) => { setupNoAuthRoutes(router, "/", { get: rootHandler }); -setupRoutes(router, "/users/:userId/preferences", { get: userPreferencesGetHandler, patch: userPreferencesPatchHandler }); +setupRoutes(router, `/${USERS}/:userId/preferences`, { get: userPreferencesGetHandler, patch: userPreferencesPatchHandler }); -setupRoutes(router, "/users/:userId/feature-toggles", { get: featureTogglesHandler }); +setupRoutes(router, `/${USERS}/:userId/feature-toggles`, { get: featureTogglesHandler }); -setupRoutes(router, `/${PROJECT_RESOURCE_NAME}`, { +setupRoutes(router, `/${PROJECTS}`, { get: projectsGetHandler, post: projectPostHandler }); -setupRoutes(router, `/${PROJECT_RESOURCE_NAME}/:projectId`, { +setupRoutes(router, `/${PROJECTS}/:projectId`, { get: projectGetHandler, patch: projectPatchHandler, delete: projectDeleteHandler }); -setupRoutes(router, `/${PROJECT_RESOURCE_NAME}/:projectId/${SPRINT_RESOURCE_NAME}/:sprintId`, { - get: projectSprintGetHandler -}); - -setupRoutes(router, `/${SPRINT_RESOURCE_NAME}`, { - get: sprintsGetHandler, - post: sprintPostHandler -}); - -setupRoutes(router, `/${SPRINT_RESOURCE_NAME}/:sprintId`, { - get: sprintGetHandler, - put: sprintPutHandler, - patch: sprintPatchHandler, - delete: sprintDeleteHandler -}); - -setupRoutes(router, `/${SPRINT_RESOURCE_NAME}/:sprintId/update-stats`, { - post: sprintUpdateStatsPostHandler -}); +{ + const SPRINTS = SPRINT_RESOURCE_NAME; + + setupRoutes(router, `/${PROJECTS}/:projectId/${SPRINTS}/:sprintId`, { + get: projectSprintGetHandler + }); + + setupRoutes(router, `/${SPRINTS}`, { + get: sprintsGetHandler, + post: sprintPostHandler + }); + + setupRoutes(router, `/${SPRINTS}/:sprintId`, { + get: sprintGetHandler, + put: sprintPutHandler, + patch: sprintPatchHandler, + delete: sprintDeleteHandler + }); + + setupRoutes(router, `/${SPRINTS}/:sprintId/update-stats`, { + post: sprintUpdateStatsPostHandler + }); +} + +{ + const SPRINTS = SPRINT_BACKLOG_PARENT_RESOURCE_NAME; + const BACKLOG_ITEMS = SPRINT_BACKLOG_CHILD_RESOURCE_NAME; + const BACKLOG_ITEMS_PARTS = SPRINT_BACKLOG_PART_CHILD_RESOURCE_NAME; + + setupRoutes(router, `/${SPRINTS}/:sprintId/${BACKLOG_ITEMS}`, { + get: sprintBacklogItemsGetHandler, + post: sprintBacklogItemPostHandler + }); + + setupRoutes(router, `/${SPRINTS}/:sprintId/${BACKLOG_ITEMS}/:backlogItemId`, { + get: sprintBacklogItemGetHandler, + delete: sprintBacklogItemDeleteHandler + }); + + setupRoutes(router, `/${SPRINTS}/:sprintId/${BACKLOG_ITEMS_PARTS}/:backlogItemPartId`, { + get: sprintBacklogItemPartGetHandler + }); -setupRoutes(router, `/${SPRINT_BACKLOG_PARENT_RESOURCE_NAME}/:sprintId/${SPRINT_BACKLOG_CHILD_RESOURCE_NAME}`, { - get: sprintBacklogItemsGetHandler, - post: sprintBacklogItemPostHandler -}); + setupRoutes(router, `/${SPRINTS}/:sprintId/${BACKLOG_ITEMS}/:backlogItemId/${SPRINT_BACKLOG_ITEM_PART_RESOURCE_NAME}`, { + post: sprintBacklogItemPartsPostHandler + }); +} -setupRoutes(router, `/${SPRINT_BACKLOG_PARENT_RESOURCE_NAME}/:sprintId/${SPRINT_BACKLOG_CHILD_RESOURCE_NAME}/:backlogItemId`, { - get: sprintBacklogItemGetHandler, - delete: sprintBacklogItemDeleteHandler -}); +{ + const BACKLOG_ITEMS = BACKLOG_ITEM_RESOURCE_NAME; -setupRoutes( - router, - `/${SPRINT_BACKLOG_PARENT_RESOURCE_NAME}/:sprintId/${SPRINT_BACKLOG_PART_CHILD_RESOURCE_NAME}/:backlogItemPartId`, - { - get: sprintBacklogItemPartGetHandler - } -); + setupRoutes(router, `/${BACKLOG_ITEMS}`, { get: backlogItemsGetHandler, post: backlogItemsPostHandler }); -setupRoutes( - router, - `/${SPRINT_BACKLOG_PARENT_RESOURCE_NAME}/:sprintId/${SPRINT_BACKLOG_CHILD_RESOURCE_NAME}/:backlogItemId/${SPRINT_BACKLOG_ITEM_PART_RESOURCE_NAME}`, - { - post: sprintBacklogItemPartsPostHandler - } -); + setupRoutes(router, `/${BACKLOG_ITEMS}/:itemId`, { + get: backlogItemGetHandler, + put: backlogItemPutHandler, + delete: backlogItemsDeleteHandler + }); -setupRoutes(router, `/${BACKLOG_ITEM_RESOURCE_NAME}`, { get: backlogItemsGetHandler, post: backlogItemsPostHandler }); + setupRoutes(router, `/${BACKLOG_ITEMS}/:itemId/join-unallocated-parts`, { + post: backlogItemJoinUnallocatedPartsPostHandler + }); +} -setupRoutes(router, `/${BACKLOG_ITEM_RESOURCE_NAME}/:itemId`, { - get: backlogItemGetHandler, - put: backlogItemPutHandler, - delete: backlogItemsDeleteHandler -}); +{ + const BACKLOG_ITEM_PARTS = BACKLOG_ITEM_PART_RESOURCE_NAME; -setupRoutes(router, `/${BACKLOG_ITEM_RESOURCE_NAME}/:itemId/join-unallocated-parts`, { - post: backlogItemJoinUnallocatedPartsPostHandler -}); + setupRoutes(router, `/${BACKLOG_ITEM_PARTS}/:itemId`, { + get: backlogItemPartGetHandler, + patch: backlogItemPartPatchHandler + }); +} -setupRoutes(router, `/${BACKLOG_ITEM_PART_RESOURCE_NAME}/:itemId`, { - get: backlogItemPartGetHandler, - patch: backlogItemPartPatchHandler -}); +{ + const PRODUCT_BACKLOG_ITEMS = PRODUCT_BACKLOG_ITEM_RESOURCE_NAME; -setupRoutes(router, `/${PRODUCT_BACKLOG_ITEM_RESOURCE_NAME}`, { get: productBacklogItemsGetHandler }); + setupRoutes(router, `/${PRODUCT_BACKLOG_ITEMS}`, { get: productBacklogItemsGetHandler }); -setupRoutes(router, `/${PRODUCT_BACKLOG_ITEM_RESOURCE_NAME}/:itemId`, { - get: productBacklogItemGetHandler -}); + setupRoutes(router, `/${PRODUCT_BACKLOG_ITEMS}/:itemId`, { + get: productBacklogItemGetHandler + }); +} // bff views setupRoutes(router, `/bff/views/plan`, { get: planViewBffGetHandler }); diff --git a/src/server/resourceNames.ts b/src/server/resourceNames.ts index c49e4314..2281d2d6 100644 --- a/src/server/resourceNames.ts +++ b/src/server/resourceNames.ts @@ -3,6 +3,7 @@ export const BACKLOG_ITEM_PART_RESOURCE_NAME = "backlog-item-parts"; export const PRODUCT_BACKLOG_ITEM_RESOURCE_NAME = "product-backlog-items"; export const BACKLOG_ITEM_RESOURCE_NAME = "backlog-items"; export const PROJECT_RESOURCE_NAME = "projects"; +export const USER_RESOURCE_NAME = "users"; export const SPRINT_BACKLOG_ITEM_PART_RESOURCE_NAME = "parts"; export const SPRINT_RESOURCE_NAME = "sprints"; From a8f8d670f1a65005713ba61ca848d7bac76bcc4e Mon Sep 17 00:00:00 2001 From: Kevin Berry <41717340+51ngul4r1ty@users.noreply.github.com> Date: Thu, 15 Sep 2022 09:56:18 -0400 Subject: [PATCH 11/13] fix for unarchive issue --- src/server/api/handlers/sprints.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/server/api/handlers/sprints.ts b/src/server/api/handlers/sprints.ts index 6574306d..358b576b 100644 --- a/src/server/api/handlers/sprints.ts +++ b/src/server/api/handlers/sprints.ts @@ -87,14 +87,15 @@ export const sprintPatchHandler = async (req: Request, res: Response) => { if (!sprint) { respondWithNotFound(res, `Unable to find sprint to patch with ID ${queryParamItemId}`); } else { - const originalSprint = mapDbToApiSprint(sprint); - const invalidPatchMessage = getInvalidPatchMessage(originalSprint, body); + const invalidPatchMessage = getInvalidPatchMessage(sprint, body); if (invalidPatchMessage) { respondWithFailedValidation(res, `Unable to patch: ${invalidPatchMessage}`); } else { - const newItem = getPatchedItem(originalSprint, body); - await sprint.update(mapApiToDbSprint(newItem)); - respondWithItem(res, sprint, originalSprint); + const originalSprint = mapDbToApiSprint(sprint); + const newItem = getPatchedItem(sprint, body); + await sprint.update(newItem); + const apiSprint = mapDbToApiSprint(sprint); + respondWithItem(res, apiSprint, originalSprint); } } } catch (err) { From 0238d5bbb90ef6631cd7b04847cfa2bb87e77fcf Mon Sep 17 00:00:00 2001 From: Kevin Berry <41717340+51ngul4r1ty@users.noreply.github.com> Date: Thu, 15 Sep 2022 21:37:57 -0400 Subject: [PATCH 12/13] switch projects --- package-lock.json | 18 +++++++++--------- package.json | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8afb3c47..c90c0ca8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "atoll", - "version": "0.61.0", + "version": "0.62.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "atoll", - "version": "0.61.0", + "version": "0.62.0", "license": "MIT", "dependencies": { - "@atoll/shared": "0.61.0", + "@atoll/shared": "0.62.0", "@flopflip/memory-adapter": "1.6.0", "@flopflip/react-broadcast": "10.1.11", "axios": "0.26.1", @@ -166,9 +166,9 @@ } }, "node_modules/@atoll/shared": { - "version": "0.61.0", - "resolved": "https://registry.npmjs.org/@atoll/shared/-/shared-0.61.0.tgz", - "integrity": "sha512-6hmj3oYXfhEc/if6xjBFuVubPBzg/EmaEeZ3KHuUmTeAixaU+sconDW59bUfdk57e33FCiIpJD18Pqj7bBin8A==", + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@atoll/shared/-/shared-0.62.0.tgz", + "integrity": "sha512-r222EXCCWNxlZTx3KzVrhUXgf58WQ1Ee3dFWTS/nmqbwDQWcAGOVOEdqvZ7jQ6aATqmh+9Du5Iw1Ve8izUSudQ==", "dependencies": { "@csstools/normalize.css": "10.1.0", "@flopflip/react-broadcast": "10.1.11", @@ -24766,9 +24766,9 @@ }, "dependencies": { "@atoll/shared": { - "version": "0.61.0", - "resolved": "https://registry.npmjs.org/@atoll/shared/-/shared-0.61.0.tgz", - "integrity": "sha512-6hmj3oYXfhEc/if6xjBFuVubPBzg/EmaEeZ3KHuUmTeAixaU+sconDW59bUfdk57e33FCiIpJD18Pqj7bBin8A==", + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@atoll/shared/-/shared-0.62.0.tgz", + "integrity": "sha512-r222EXCCWNxlZTx3KzVrhUXgf58WQ1Ee3dFWTS/nmqbwDQWcAGOVOEdqvZ7jQ6aATqmh+9Du5Iw1Ve8izUSudQ==", "requires": { "@csstools/normalize.css": "10.1.0", "@flopflip/react-broadcast": "10.1.11", diff --git a/package.json b/package.json index 4b909041..0e025c2c 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "check:prereqs": "ts-node ./scripts/check-prereqs.ts" }, "dependencies": { - "@atoll/shared": "file:.yalc/@atoll/shared", + "@atoll/shared": "0.62.0", "@flopflip/memory-adapter": "1.6.0", "@flopflip/react-broadcast": "10.1.11", "axios": "0.26.1", From 9296b4c559c159d0f2cb7633b7c4cf3d9d6da590 Mon Sep 17 00:00:00 2001 From: Kevin Berry <41717340+51ngul4r1ty@users.noreply.github.com> Date: Thu, 15 Sep 2022 21:39:16 -0400 Subject: [PATCH 13/13] remove comment --- src/server/api/handlers/backlogItemParts.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/server/api/handlers/backlogItemParts.ts b/src/server/api/handlers/backlogItemParts.ts index c10206d6..5beab2ba 100644 --- a/src/server/api/handlers/backlogItemParts.ts +++ b/src/server/api/handlers/backlogItemParts.ts @@ -191,7 +191,6 @@ const handleResponseWithUpdatedStatsAndCommit = async ( } if (transaction) { await transaction.commit(); - // TODO: Is this supposed to set the transaction object in handlerContext to null? Not sure it does that. transaction = null; } const extra = sprintStats ? { sprintStats, backlogItem: newApiBacklogItem } : { backlogItem: newApiBacklogItem };