diff --git a/package-lock.json b/package-lock.json index 43361dd70..5be5b19e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23451,7 +23451,7 @@ } }, "packages/backend": { - "version": "1.30.0", + "version": "1.31.0", "dependencies": { "@apollo/server": "4.9.4", "@aws-sdk/client-dynamodb": "3.460.0", @@ -23551,7 +23551,7 @@ } }, "packages/frontend": { - "version": "1.30.0", + "version": "1.31.0", "hasInstallScript": true, "dependencies": { "@datadog/browser-rum": "^5.2.0", @@ -23762,7 +23762,7 @@ }, "packages/types": { "name": "@plumber/types", - "version": "1.30.0" + "version": "1.31.0" } } } diff --git a/packages/backend/package.json b/packages/backend/package.json index e4bbb6ac9..695fb6cfe 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -105,5 +105,5 @@ "tsconfig-paths": "^4.2.0", "type-fest": "4.10.3" }, - "version": "1.30.0" + "version": "1.31.0" } diff --git a/packages/backend/src/apps/formsg/auth/index.ts b/packages/backend/src/apps/formsg/auth/index.ts index 33875046f..7e1c94e46 100644 --- a/packages/backend/src/apps/formsg/auth/index.ts +++ b/packages/backend/src/apps/formsg/auth/index.ts @@ -28,12 +28,13 @@ const auth: IUserAddedConnectionAuth = { { key: 'privateKey', label: 'Form Secret Key', - type: 'string' as const, + type: 'dragdrop' as const, required: true, readOnly: false, value: null, description: 'This is the key you downloaded/saved when you created the form', + placeholder: 'Enter or drop your Secret Key here to continue', clickToCopy: false, autoComplete: 'off' as const, }, diff --git a/packages/backend/src/apps/postman/__tests__/actions/send-transactional-email.test.ts b/packages/backend/src/apps/postman/__tests__/actions/send-transactional-email.test.ts index 3157bbef8..fd8917409 100644 --- a/packages/backend/src/apps/postman/__tests__/actions/send-transactional-email.test.ts +++ b/packages/backend/src/apps/postman/__tests__/actions/send-transactional-email.test.ts @@ -225,6 +225,25 @@ describe('send transactional email', () => { }) }) + it('should send to CC recipients', async () => { + const recipients = ['recipient1@open.gov.sg', 'recipient2@open.gov.sg'] + const ccRecipients = ['cc1@open.gov.sg', 'cc2@open.gov.sg'] + $.step.parameters.destinationEmail = recipients.join(',') + $.step.parameters.destinationEmailCc = ccRecipients.join(',') + await expect(sendTransactionalEmail.run($)).resolves.not.toThrow() + expect($.setActionItem).toHaveBeenCalledWith({ + raw: { + status: ['ACCEPTED', 'ACCEPTED'], + recipient: recipients, + subject: 'test subject', + body: 'test body', + cc: ccRecipients, + from: 'jack', + reply_to: 'replyTo@open.gov.sg', + }, + }) + }) + it('should throw partial step error if one succeeds while the rest are blacklists', async () => { const recipients = ['recipient1@open.gov.sg', 'recipient2@open.gov.sg'] $.step.parameters.destinationEmail = recipients.join(',') @@ -444,6 +463,7 @@ describe('send transactional email', () => { recipient: recipients, }, }) + $.execution.testRun = false await expect(sendTransactionalEmail.run($)).resolves.not.toThrow() expect($.http.post).toBeCalledTimes(4) expect($.setActionItem).toHaveBeenCalledWith({ @@ -457,4 +477,42 @@ describe('send transactional email', () => { }, }) }) + + it('should send to all recipients in test runs', async () => { + const recipients = [ + 'recipient1@open.gov.sg', + 'recipient2@open.gov.sg', + 'recipient3@open.gov.sg', + 'recipient4@open.gov.sg', + 'recipient5@open.gov.sg', + ] + $.step.parameters.destinationEmail = recipients.join(',') + $.getLastExecutionStep = vi.fn().mockResolvedValueOnce({ + status: 'success', + errorDetails: 'error error', + dataOut: { + status: [ + 'BLACKLISTED', + 'ACCEPTED', + 'INTERMITTENT-ERROR', + 'ERROR', + 'RATE-LIMITED', + ], + recipient: recipients, + }, + }) + $.execution.testRun = true + await expect(sendTransactionalEmail.run($)).resolves.not.toThrow() + expect($.http.post).toBeCalledTimes(5) + expect($.setActionItem).toHaveBeenCalledWith({ + raw: { + status: ['ACCEPTED', 'ACCEPTED', 'ACCEPTED', 'ACCEPTED', 'ACCEPTED'], + recipient: recipients, + subject: 'test subject', + body: 'test body', + from: 'jack', + reply_to: 'replyTo@open.gov.sg', + }, + }) + }) }) diff --git a/packages/backend/src/apps/postman/__tests__/common/parameters.test.ts b/packages/backend/src/apps/postman/__tests__/common/parameters.test.ts index 4a988cc0d..0158e214d 100644 --- a/packages/backend/src/apps/postman/__tests__/common/parameters.test.ts +++ b/packages/backend/src/apps/postman/__tests__/common/parameters.test.ts @@ -129,4 +129,30 @@ describe('postman transactional email schema zod validation', () => { assert(result.success === true) expect(result.data.body).toBe('hello
hihi') }) + + it('should validate multiple valid CC emails', () => { + validPayload.destinationEmailCc = + 'recipientCc@example.com, recipientCc2@example.com,recipientCc3@example.com' + const result = transactionalEmailSchema.safeParse(validPayload) + assert(result.success === true) // using assert here for type assertion + expect(result.data.destinationEmailCc).toEqual([ + 'recipientCc@example.com', + 'recipientCc2@example.com', + 'recipientCc3@example.com', + ]) + }) + + const invalidCcRecipients: string[][] = [ + ['invalid-cc-recipient'], + ['invalid-cc-recipient2', 'valid@email.com'], + ] + it.each(invalidCcRecipients)( + 'should fail if invalid CC recipient', + (...ccRecipients: string[]) => { + validPayload.destinationEmailCc = ccRecipients.join(',') + const result = transactionalEmailSchema.safeParse(validPayload) + assert(result.success === false) + expect(result.error?.errors[0].message).toEqual('Invalid CC emails') + }, + ) }) diff --git a/packages/backend/src/apps/postman/actions/send-transactional-email/index.ts b/packages/backend/src/apps/postman/actions/send-transactional-email/index.ts index f89a6cf8e..9c087dcf8 100644 --- a/packages/backend/src/apps/postman/actions/send-transactional-email/index.ts +++ b/packages/backend/src/apps/postman/actions/send-transactional-email/index.ts @@ -35,12 +35,14 @@ const action: IRawAction = { subject, body, destinationEmail, + destinationEmailCc, senderName, replyTo, attachments = [], } = $.step.parameters const result = transactionalEmailSchema.safeParse({ destinationEmail, + destinationEmailCc, senderName, subject, body, @@ -94,7 +96,10 @@ const action: IRawAction = { lastExecutionStep?.dataOut, ) const isPartialRetry = - prevDataOutParseResult.success && lastExecutionStep.errorDetails + prevDataOutParseResult.success && + lastExecutionStep.errorDetails && + // Don't do partial retry in test runs! always send to all recipients + !$.execution.testRun if (isPartialRetry) { const { status, recipient } = prevDataOutParseResult.data @@ -112,6 +117,7 @@ const action: IRawAction = { /()\s*(<\/p>)/g, '

 

', ), + ccList: result.data.destinationEmailCc, replyTo: result.data.replyTo, senderName: result.data.senderName, attachments: attachmentFiles, diff --git a/packages/backend/src/apps/postman/common/email-helper.ts b/packages/backend/src/apps/postman/common/email-helper.ts index b13462f6a..32c361649 100644 --- a/packages/backend/src/apps/postman/common/email-helper.ts +++ b/packages/backend/src/apps/postman/common/email-helper.ts @@ -55,6 +55,7 @@ interface Email { senderName: string attachments?: { fileName: string; data: Uint8Array }[] replyTo?: string + ccList?: string[] } interface PostmanPromiseFulfilled { @@ -88,6 +89,9 @@ export async function sendTransactionalEmails( `${email.senderName} <${appConfig.postman.fromAddress}>`, ) requestData.append('disable_tracking', 'true') + if (email.ccList?.length > 0) { + requestData.append('cc', JSON.stringify(email.ccList)) + } if (email.replyTo) { requestData.append('reply_to', email.replyTo) @@ -122,6 +126,7 @@ export async function sendTransactionalEmails( subject, from, reply_to, + ...(email.ccList?.length && { cc: email.ccList }), }, } satisfies PostmanPromiseFulfilled } catch (e) { diff --git a/packages/backend/src/apps/postman/common/parameters.ts b/packages/backend/src/apps/postman/common/parameters.ts index 3cb9f4436..78b99d8ed 100644 --- a/packages/backend/src/apps/postman/common/parameters.ts +++ b/packages/backend/src/apps/postman/common/parameters.ts @@ -15,6 +15,17 @@ function recipientStringToArray(value: string) { return uniq(recipientArray) } +function validateEmails(value: string, ctx: z.RefinementCtx, msg: string) { + const recipients = recipientStringToArray(value) + if (recipients.some((recipient) => !validator.validate(recipient))) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: msg, + }) + } + return recipients +} + export const transactionalEmailFields: IField[] = [ { label: 'Email subject', @@ -36,7 +47,16 @@ export const transactionalEmailFields: IField[] = [ type: 'string' as const, required: true, description: - 'To send to multiple recipients, comma-separate email addresses. Emails will be sent to each email address separately.', + 'Enter the email addresses of the main recipients, separated by commas.\nEach recipient will receive an individual email.', + variables: true, + }, + { + label: 'CC recipient email(s)', + key: 'destinationEmailCc', + type: 'string' as const, + required: false, + description: + 'Enter the email addresses to CC, separated by commas.\nCC recipients will receive a copy of the email for each main recipient.', variables: true, }, { @@ -44,7 +64,7 @@ export const transactionalEmailFields: IField[] = [ key: 'senderName', type: 'string' as const, required: true, - description: 'For e.g., HR department', + description: 'For e.g., HR department.', variables: true, }, { @@ -52,7 +72,7 @@ export const transactionalEmailFields: IField[] = [ key: 'replyTo', type: 'string' as const, required: false, - description: 'If left blank, this will default to your email address', + description: 'If left blank, this will default to your email address.', variables: true, }, { @@ -74,16 +94,15 @@ export const transactionalEmailSchema = z.object({ .min(1, { message: 'Empty body' }) // for backward-compatibility with content produced by the old editor .transform((v) => v.replace(/\n/g, '
')), - destinationEmail: z.string().transform((value, ctx) => { - const recipients = recipientStringToArray(value) - if (recipients.some((recipient) => !validator.validate(recipient))) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Invalid recipient emails', - }) - } - return recipients - }), + destinationEmail: z + .string() + .transform((value, ctx) => + validateEmails(value, ctx, 'Invalid recipient emails'), + ), + destinationEmailCc: z + .string() + .transform((value, ctx) => validateEmails(value, ctx, 'Invalid CC emails')) + .optional(), replyTo: z.preprocess((value) => { if (typeof value !== 'string') { return value diff --git a/packages/backend/src/apps/tiles/__tests__/actions/create-row.itest.ts b/packages/backend/src/apps/tiles/__tests__/actions/create-row.itest.ts index 5421c28e7..2a6492a7f 100644 --- a/packages/backend/src/apps/tiles/__tests__/actions/create-row.itest.ts +++ b/packages/backend/src/apps/tiles/__tests__/actions/create-row.itest.ts @@ -83,4 +83,14 @@ describe('tiles create row action', () => { $.user = viewer await expect(createRowAction.run($)).rejects.toThrow(StepError) }) + + it('should throw correct error if Tile deleted', async () => { + $.user = editor + await TableMetadata.query() + .patch({ + deletedAt: new Date().toISOString(), + }) + .where({ id: $.step.parameters.tableId }) + await expect(createRowAction.run($)).rejects.toThrow(StepError) + }) }) diff --git a/packages/backend/src/apps/tiles/__tests__/actions/find-single-row.itest.ts b/packages/backend/src/apps/tiles/__tests__/actions/find-single-row.itest.ts index 987c06867..5c24a9383 100644 --- a/packages/backend/src/apps/tiles/__tests__/actions/find-single-row.itest.ts +++ b/packages/backend/src/apps/tiles/__tests__/actions/find-single-row.itest.ts @@ -10,6 +10,7 @@ import { generateMockTableRowData, } from '@/graphql/__tests__/mutations/tiles/table.mock' import { createTableRow, TableRowFilter } from '@/models/dynamodb/table-row' +import TableColumnMetadata from '@/models/table-column-metadata' import TableMetadata from '@/models/table-metadata' import User from '@/models/user' import Context from '@/types/express/context' @@ -91,4 +92,19 @@ describe('tiles create row action', () => { $.user = viewer await expect(findSingleRowAction.run($)).rejects.toThrow(StepError) }) + + it('should throw correct error if Tile deleted', async () => { + $.user = editor + await TableMetadata.query() + .patch({ + deletedAt: new Date().toISOString(), + }) + .where({ id: $.step.parameters.tableId }) + await TableColumnMetadata.query() + .patch({ + deletedAt: new Date().toISOString(), + }) + .where({ table_id: $.step.parameters.tableId }) + await expect(findSingleRowAction.run($)).rejects.toThrow(StepError) + }) }) diff --git a/packages/backend/src/apps/tiles/__tests__/actions/update-row.itest.ts b/packages/backend/src/apps/tiles/__tests__/actions/update-row.itest.ts index 21c6ab559..e3aa3ab71 100644 --- a/packages/backend/src/apps/tiles/__tests__/actions/update-row.itest.ts +++ b/packages/backend/src/apps/tiles/__tests__/actions/update-row.itest.ts @@ -10,6 +10,7 @@ import { generateMockTableRowData, } from '@/graphql/__tests__/mutations/tiles/table.mock' import { createTableRow } from '@/models/dynamodb/table-row' +import TableColumnMetadata from '@/models/table-column-metadata' import TableMetadata from '@/models/table-metadata' import User from '@/models/user' import Context from '@/types/express/context' @@ -92,4 +93,24 @@ describe('tiles create row action', () => { $.user = viewer await expect(updateRowAction.run($)).rejects.toThrow(StepError) }) + + it('should throw error if no tableId', async () => { + $.step.parameters.tableId = '' + await expect(updateRowAction.run($)).rejects.toThrow(StepError) + }) + + it('should throw correct error if Tile deleted', async () => { + $.user = editor + await TableMetadata.query() + .patch({ + deletedAt: new Date().toISOString(), + }) + .where({ id: $.step.parameters.tableId }) + await TableColumnMetadata.query() + .patch({ + deletedAt: new Date().toISOString(), + }) + .where({ table_id: $.step.parameters.tableId }) + await expect(updateRowAction.run($)).rejects.toThrow(StepError) + }) }) diff --git a/packages/backend/src/apps/tiles/actions/create-row/index.ts b/packages/backend/src/apps/tiles/actions/create-row/index.ts index e27f0a3f5..682f81fcc 100644 --- a/packages/backend/src/apps/tiles/actions/create-row/index.ts +++ b/packages/backend/src/apps/tiles/actions/create-row/index.ts @@ -94,9 +94,17 @@ const action: IRawAction = { rowData: { columnId: string; cellValue: string }[] } - await TableCollaborator.hasAccess($.user?.id, tableId, 'editor', $) - const table = await TableMetadata.query().findById(tableId) + if (!table) { + throw new StepError( + 'Tile not found', + 'Tile may have been deleted. Please check your tile.', + $.step.position, + 'tiles', + ) + } + + await TableCollaborator.hasAccess($.user?.id, tableId, 'editor', $) /** * convert array to object @@ -112,7 +120,7 @@ const action: IRawAction = { 'Invalid column ID(s)', 'Column(s) may have been deleted or modified. Please check your tile and pipe setup.', $.step.position, - 'tiles', + $.app.name, ) } diff --git a/packages/backend/src/apps/tiles/actions/find-single-row/index.ts b/packages/backend/src/apps/tiles/actions/find-single-row/index.ts index 7955da042..fea8b38f6 100644 --- a/packages/backend/src/apps/tiles/actions/find-single-row/index.ts +++ b/packages/backend/src/apps/tiles/actions/find-single-row/index.ts @@ -149,11 +149,12 @@ const action: IRawAction = { returnLastRow: boolean | undefined } - await TableCollaborator.hasAccess($.user?.id, tableId, 'editor', $) + /** + * Check for columns first, there will not be any columns if the tile has been deleted. + */ + const columns = await TableColumnMetadata.getColumns(tableId, $) - const columns = await TableColumnMetadata.query().where({ - table_id: tableId, - }) + await TableCollaborator.hasAccess($.user?.id, tableId, 'editor', $) // Check that filters are valid try { diff --git a/packages/backend/src/apps/tiles/actions/update-row/index.ts b/packages/backend/src/apps/tiles/actions/update-row/index.ts index 9bb8639cd..9096caacd 100644 --- a/packages/backend/src/apps/tiles/actions/update-row/index.ts +++ b/packages/backend/src/apps/tiles/actions/update-row/index.ts @@ -108,6 +108,12 @@ const action: IRawAction = { ) } + /** + * Check for columns first, there will not be any columns if the tile has been deleted. + */ + const columns = await TableColumnMetadata.getColumns(tableId, $) + const columnIds = columns.map((c) => c.id) + await TableCollaborator.hasAccess($.user?.id, tableId, 'editor', $) /** @@ -123,13 +129,6 @@ const action: IRawAction = { return } - const columns = await TableColumnMetadata.query() - .where({ - table_id: tableId, - }) - .select('id', 'name') - const columnIds = columns.map((c) => c.id) - const row = await getRawRowById({ tableId, rowId, diff --git a/packages/backend/src/apps/tiles/dynamic-data/list-columns.ts b/packages/backend/src/apps/tiles/dynamic-data/list-columns.ts index afa1e3a95..e77efaf85 100644 --- a/packages/backend/src/apps/tiles/dynamic-data/list-columns.ts +++ b/packages/backend/src/apps/tiles/dynamic-data/list-columns.ts @@ -26,7 +26,9 @@ const dynamicData: IDynamicData = { .$relatedQuery('tables') .findById($.step.parameters.tableId as string) .whereIn('role', ['owner', 'editor']) - .throwIfNotFound() + .throwIfNotFound({ + message: 'Tile may have been deleted. Please check your tile.', + }) const columns = await tile .$relatedQuery('columns') .orderBy('position', 'asc') @@ -38,14 +40,17 @@ const dynamicData: IDynamicData = { name: name, })), } - } catch (e) { - logger.error('Tiles dynamic data: list columns error', { - userId: $.user?.id, - tableId: $.step.parameters.tableId, - flowId: $.flow?.id, - stepId: $.step?.id, - }) - throw new Error('Unable to fetch columns') + } catch (err) { + logger.error( + err.data.message ?? 'Tiles dynamic data: list columns error', + { + userId: $.user?.id, + tableId: $.step.parameters.tableId, + flowId: $.flow?.id, + stepId: $.step?.id, + }, + ) + throw new Error(err.data.message ?? 'Unable to fetch columns') } }, } diff --git a/packages/backend/src/db/migrations/20241119090013_add_user_last_login_at.ts b/packages/backend/src/db/migrations/20241119090013_add_user_last_login_at.ts new file mode 100644 index 000000000..4a4db4b08 --- /dev/null +++ b/packages/backend/src/db/migrations/20241119090013_add_user_last_login_at.ts @@ -0,0 +1,13 @@ +import { Knex } from 'knex' + +export async function up(knex: Knex): Promise { + return knex.schema.table('users', (table) => { + table.timestamp('last_login_at').nullable() + }) +} + +export async function down(knex: Knex): Promise { + return knex.schema.table('users', (table) => { + table.dropColumn('last_login_at') + }) +} diff --git a/packages/backend/src/graphql/__tests__/mutations/login-with-selected-sgid.test.ts b/packages/backend/src/graphql/__tests__/mutations/login-with-selected-sgid.test.ts index 5abe8531c..eb0795725 100644 --- a/packages/backend/src/graphql/__tests__/mutations/login-with-selected-sgid.test.ts +++ b/packages/backend/src/graphql/__tests__/mutations/login-with-selected-sgid.test.ts @@ -8,6 +8,7 @@ import type Context from '@/types/express/context' const mocks = vi.hoisted(() => ({ setAuthCookie: vi.fn(), getOrCreateUser: vi.fn(), + updateLastLogin: vi.fn(), logError: vi.fn(), verifyJwt: vi.fn(), })) @@ -15,6 +16,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/helpers/auth', () => ({ setAuthCookie: mocks.setAuthCookie, getOrCreateUser: mocks.getOrCreateUser, + updateLastLogin: mocks.updateLastLogin, })) vi.mock('jsonwebtoken', () => ({ @@ -79,6 +81,7 @@ describe('Login with selected SGID', () => { expect(mocks.getOrCreateUser).toHaveBeenCalledWith( 'loong_loong@coffee.gov.sg', ) + expect(mocks.updateLastLogin).toHaveBeenCalledWith('abc-def') expect(mocks.setAuthCookie).toHaveBeenCalledWith(expect.anything(), { userId: 'abc-def', }) @@ -118,6 +121,7 @@ describe('Login with selected SGID', () => { ).rejects.toThrowError('Invalid work email') expect(mocks.getOrCreateUser).not.toBeCalled() + expect(mocks.updateLastLogin).not.toBeCalled() expect(mocks.setAuthCookie).not.toBeCalled() }) diff --git a/packages/backend/src/graphql/__tests__/mutations/login-with-sgid.test.ts b/packages/backend/src/graphql/__tests__/mutations/login-with-sgid.test.ts index 7234a1b25..9e0d1b75d 100644 --- a/packages/backend/src/graphql/__tests__/mutations/login-with-sgid.test.ts +++ b/packages/backend/src/graphql/__tests__/mutations/login-with-sgid.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ sgidUserInfo: vi.fn(), setAuthCookie: vi.fn(), getOrCreateUser: vi.fn(), + updateLastLogin: vi.fn(), isWhitelistedEmail: vi.fn(), logError: vi.fn(), setCookie: vi.fn(), @@ -44,6 +45,7 @@ vi.mock('@opengovsg/sgid-client', () => ({ vi.mock('@/helpers/auth', () => ({ setAuthCookie: mocks.setAuthCookie, getOrCreateUser: mocks.getOrCreateUser, + updateLastLogin: mocks.updateLastLogin, })) vi.mock('@/models/login-whitelist-entry', () => ({ @@ -89,6 +91,7 @@ describe('Login with SGID', () => { expect(mocks.getOrCreateUser).toHaveBeenCalledWith( 'loong_loong@coffee.gov.sg', ) + expect(mocks.updateLastLogin).toHaveBeenCalledWith('abc-def') expect(mocks.setAuthCookie).toHaveBeenCalledWith(expect.anything(), { userId: 'abc-def', }) @@ -139,6 +142,7 @@ describe('Login with SGID', () => { const result = await loginWithSgid(null, STUB_PARAMS, STUB_CONTEXT) expect(mocks.getOrCreateUser).not.toBeCalled() + expect(mocks.updateLastLogin).not.toBeCalled() expect(mocks.setAuthCookie).not.toBeCalled() expect(result.publicOfficerEmployments).toEqual([]) }) @@ -176,6 +180,7 @@ describe('Login with SGID', () => { const result = await loginWithSgid(null, STUB_PARAMS, STUB_CONTEXT) expect(mocks.getOrCreateUser).toHaveBeenCalledWith('loong@tea.gov.sg') + expect(mocks.updateLastLogin).toHaveBeenCalledWith('abc-def') expect(mocks.setAuthCookie).toHaveBeenCalledWith(expect.anything(), { userId: 'abc-def', }) @@ -254,6 +259,7 @@ describe('Login with SGID', () => { }, ) expect(mocks.getOrCreateUser).not.toBeCalled() + expect(mocks.updateLastLogin).not.toBeCalled() expect(mocks.setAuthCookie).not.toBeCalled() }) @@ -270,6 +276,7 @@ describe('Login with SGID', () => { event: 'sgid-login-failed-user-info', }) expect(mocks.getOrCreateUser).not.toBeCalled() + expect(mocks.updateLastLogin).not.toBeCalled() expect(mocks.setAuthCookie).not.toBeCalled() }) diff --git a/packages/backend/src/graphql/__tests__/mutations/tiles/delete-table.itest.ts b/packages/backend/src/graphql/__tests__/mutations/tiles/delete-table.itest.ts index 10661875b..e6b5b2da1 100644 --- a/packages/backend/src/graphql/__tests__/mutations/tiles/delete-table.itest.ts +++ b/packages/backend/src/graphql/__tests__/mutations/tiles/delete-table.itest.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { ForbiddenError } from '@/errors/graphql-errors' import deleteTable from '@/graphql/mutations/tiles/delete-table' +import TableCollaborator from '@/models/table-collaborators' import TableMetadata from '@/models/table-metadata' import User from '@/models/user' import Context from '@/types/express/context' @@ -35,7 +36,7 @@ describe('delete table mutation', () => { }) }) - it('should delete table and columns', async () => { + it('should delete table, columns and collaborators', async () => { const success = await deleteTable( null, { input: { id: dummyTable.id } }, @@ -46,9 +47,13 @@ describe('delete table mutation', () => { .resultSize() const deletedTable = await TableMetadata.query().findById(dummyTable.id) + const tableCollaborators = await TableCollaborator.query().where({ + table_id: dummyTable.id, + }) expect(success).toBe(true) expect(deletedTable).toBeUndefined() expect(tableColumnCount).toBe(0) + expect(tableCollaborators.length).toBe(0) }) it('should throw an error if table is not found', async () => { diff --git a/packages/backend/src/graphql/__tests__/mutations/tiles/table.mock.ts b/packages/backend/src/graphql/__tests__/mutations/tiles/table.mock.ts index 79169faef..633629d9f 100644 --- a/packages/backend/src/graphql/__tests__/mutations/tiles/table.mock.ts +++ b/packages/backend/src/graphql/__tests__/mutations/tiles/table.mock.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'crypto' +import Step from '@/models/step' import TableCollaborator from '@/models/table-collaborators' import TableColumnMetadata from '@/models/table-column-metadata' import TableMetadata from '@/models/table-metadata' @@ -52,6 +53,56 @@ export async function generateMockTable({ } } +// generate mock flow including steps +export async function generateMockFlow({ + userId, + tableId, + numSteps, +}: { + userId: string + tableId: string + numSteps: number +}) { + const currentUser = await User.query().findById(userId) + + const tableName = `test-flow-${tableId}` + const flowRes = await currentUser.$relatedQuery('flows').insert({ + name: tableName, + }) + const flowId = flowRes.id + + for (let i = 0; i < numSteps; i++) { + await generateMockStep({ + tableId, + flowId, + position: i + 2, + }) + } +} + +export async function generateMockStep({ + tableId, + flowId, + position, +}: { + tableId: string + flowId: string + position?: number +}) { + await Step.query().insert({ + key: 'createTileRow', + appKey: 'tiles', + type: 'action', + parameters: { + rowData: [], + tableId: tableId, + }, + flowId: flowId, + status: 'incomplete', + position: position, + }) +} + export async function generateMockTableColumns({ tableId, numColumns = 5, diff --git a/packages/backend/src/graphql/__tests__/queries/tiles/get-table-connections.itest.ts b/packages/backend/src/graphql/__tests__/queries/tiles/get-table-connections.itest.ts new file mode 100644 index 000000000..5c807de56 --- /dev/null +++ b/packages/backend/src/graphql/__tests__/queries/tiles/get-table-connections.itest.ts @@ -0,0 +1,231 @@ +import crypto from 'crypto' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import getTableConnections from '@/graphql/queries/tiles/get-table-connections' +import getTables from '@/graphql/queries/tiles/get-tables' +import Flow from '@/models/flow' +import Context from '@/types/express/context' + +import { + generateMockContext, + generateMockFlow, + generateMockTable, +} from '../../mutations/tiles/table.mock' + +interface TablePipeCountObj { + [key: string]: number +} + +function getRandNum() { + return crypto.randomInt(1, 6) +} + +describe('get table connections query', () => { + let context: Context + + beforeEach(async () => { + context = await generateMockContext() + }) + + it('should return empty object if no tables found', async () => { + const { edges } = await getTables(null, { limit: 10, offset: 0 }, context) + const tables = edges.map((edge) => edge.node) + expect(tables).toHaveLength(0) + + const tableConnections = await getTableConnections( + null, + { tableIds: [] }, + context, + ) + const flowQuerySpy = vi.spyOn(Flow, 'query') + expect(flowQuerySpy).not.toHaveBeenCalled() + expect(tableConnections).toEqual({}) + expect(Object.keys(tableConnections).length).toBe(0) + }) + + it('should return empty object if user has no access to any tables', async () => { + const numTables = 5 + const testIds = Array.from({ length: numTables }, () => crypto.randomUUID()) + + const tableConnections = await getTableConnections( + null, + { tableIds: testIds }, + context, + ) + expect(tableConnections).toEqual({}) + expect(Object.keys(tableConnections).length).toBe(0) + }) + + it('should return the correct number of table connections for user flows only', async () => { + const numTables = 5 + const tablePipeCount: TablePipeCountObj = {} + for (let i = 0; i < numTables; i++) { + const res = await generateMockTable({ userId: context.currentUser.id }) + const { id: tableId } = res.table + + const [numFlows, numSteps] = [getRandNum(), getRandNum()] + tablePipeCount[tableId] = numFlows + + for (let i = 0; i < numFlows; i++) { + await generateMockFlow({ + userId: context.currentUser.id, + tableId, + numSteps, + }) + } + } + + // tests each page + const limit = 2 + for (let i = 0; i < Math.ceil(numTables / 2); i++) { + const offset = limit * i + const { edges, pageInfo } = await getTables( + null, + { limit, offset }, + context, + ) + + const pageTables = edges.map((edge) => edge.node) + const expectedLength = Math.min(limit, numTables - limit * i) + expect(pageTables).toHaveLength(expectedLength) + expect(pageInfo.currentPage).toBe(i + 1) + expect(pageInfo.totalCount).toBe(numTables) + + const pageTableIds = edges.map((edge) => edge.node.id) + + const tableConnections = await getTableConnections( + null, + { tableIds: pageTableIds }, + context, + ) + expect(Object.keys(tableConnections).length).toBe(expectedLength) + expect(Object.keys(tableConnections).sort()).toEqual(pageTableIds.sort()) + + Object.entries(tableConnections).forEach(([key, value]) => { + expect(value).toBe(tablePipeCount[key]) + }) + } + }) + + it('should return the correct number of table connections for flows by user and editor', async () => { + const numTables = 5 + const tablePipeCount: TablePipeCountObj = {} + for (let i = 0; i < numTables; i++) { + const res = await generateMockTable({ userId: context.currentUser.id }) + const { id: tableId } = res.table + const { id: editorId } = res.editor + + const [numFlows, numSteps] = [getRandNum(), getRandNum()] + const [editorFlows, editorSteps] = [getRandNum(), getRandNum()] + + tablePipeCount[tableId] = numFlows + editorFlows + for (let i = 0; i < numFlows; i++) { + await generateMockFlow({ + userId: context.currentUser.id, + tableId, + numSteps, + }) + } + for (let i = 0; i < editorFlows; i++) { + await generateMockFlow({ + userId: editorId, + tableId, + numSteps: editorSteps, + }) + } + } + + // tests each page + const limit = 2 + for (let i = 0; i < Math.ceil(numTables / 2); i++) { + const offset = limit * i + const { edges, pageInfo } = await getTables( + null, + { limit, offset }, + context, + ) + + const pageTables = edges.map((edge) => edge.node) + const expectedLength = Math.min(limit, numTables - limit * i) + expect(pageTables).toHaveLength(expectedLength) + expect(pageInfo.currentPage).toBe(i + 1) + expect(pageInfo.totalCount).toBe(numTables) + + const pageTableIds = edges.map((edge) => edge.node.id) + + const tableConnections = await getTableConnections( + null, + { tableIds: pageTableIds }, + context, + ) + expect(Object.keys(tableConnections).length).toBe(expectedLength) + expect(Object.keys(tableConnections).sort()).toEqual(pageTableIds.sort()) + + Object.entries(tableConnections).forEach(([key, value]) => { + expect(value).toBe(tablePipeCount[key]) + }) + } + }) + + it('should not return table connections for tables the user does not have access to', async () => { + const numTables = 5 + const tablePipeCount: TablePipeCountObj = {} + for (let i = 0; i < numTables; i++) { + const res = await generateMockTable({ userId: context.currentUser.id }) + const { id: tableId } = res.table + const { id: editorId } = res.editor + + const [numFlows, numSteps] = [getRandNum(), getRandNum()] + const [editorFlows, editorSteps] = [getRandNum(), getRandNum()] + + tablePipeCount[tableId] = numFlows + editorFlows + for (let i = 0; i < numFlows; i++) { + await generateMockFlow({ + userId: context.currentUser.id, + tableId, + numSteps, + }) + } + for (let i = 0; i < editorFlows; i++) { + await generateMockFlow({ + userId: editorId, + tableId, + numSteps: editorSteps, + }) + } + } + + // test as a whole + const { edges, pageInfo } = await getTables( + null, + { limit: 10, offset: 0 }, + context, + ) + + const pageTables = edges.map((edge) => edge.node) + expect(pageTables).toHaveLength(numTables) + expect(pageInfo.currentPage).toBe(1) + expect(pageInfo.totalCount).toBe(numTables) + + const pageTableIds = edges.map((edge) => edge.node.id) + const testIds = [...pageTableIds, crypto.randomUUID()] // add a random table id + expect(testIds).toHaveLength(numTables + 1) + const tableConnections = await getTableConnections( + null, + { tableIds: testIds }, + context, + ) + expect(Object.keys(tableConnections).length).toBe(numTables) + expect(Object.keys(tableConnections).sort()).toEqual(pageTableIds.sort()) + + Object.entries(tableConnections).forEach(([key, value]) => { + expect(value).toBe(tablePipeCount[key]) + }) + }) + + it('should throw error if tableIds is null', async () => { + await expect( + getTableConnections(null, { tableIds: null }, context), + ).rejects.toThrow('tableIds is required') + }) +}) diff --git a/packages/backend/src/graphql/mutations/login-with-selected-sgid.ts b/packages/backend/src/graphql/mutations/login-with-selected-sgid.ts index ec86769bb..3c78a45cc 100644 --- a/packages/backend/src/graphql/mutations/login-with-selected-sgid.ts +++ b/packages/backend/src/graphql/mutations/login-with-selected-sgid.ts @@ -2,7 +2,7 @@ import { type Request } from 'express' import { verify as verifyJwt } from 'jsonwebtoken' import appConfig from '@/config/app' -import { getOrCreateUser, setAuthCookie } from '@/helpers/auth' +import { getOrCreateUser, setAuthCookie, updateLastLogin } from '@/helpers/auth' import logger from '@/helpers/logger' import { type PublicOfficerEmployment, @@ -44,6 +44,7 @@ const loginWithSelectedSgid: MutationResolvers['loginWithSelectedSgid'] = } const user = await getOrCreateUser(workEmail) + await updateLastLogin(user.id) setAuthCookie(context.res, { userId: user.id }) return { diff --git a/packages/backend/src/graphql/mutations/login-with-sgid.ts b/packages/backend/src/graphql/mutations/login-with-sgid.ts index 419d1fe7f..b70af85f6 100644 --- a/packages/backend/src/graphql/mutations/login-with-sgid.ts +++ b/packages/backend/src/graphql/mutations/login-with-sgid.ts @@ -3,7 +3,7 @@ import { type Response } from 'express' import { sign as signJwt } from 'jsonwebtoken' import appConfig from '@/config/app' -import { getOrCreateUser, setAuthCookie } from '@/helpers/auth' +import { getOrCreateUser, setAuthCookie, updateLastLogin } from '@/helpers/auth' import { validateAndParseEmail } from '@/helpers/email-validator' import logger from '@/helpers/logger' import { @@ -129,6 +129,7 @@ const loginWithSgid: MutationResolvers['loginWithSgid'] = async ( // Log user in directly if there is only 1 employment. if (publicOfficerEmployments.length === 1) { const user = await getOrCreateUser(publicOfficerEmployments[0].workEmail) + await updateLastLogin(user.id) setAuthCookie(context.res, { userId: user.id }) return { diff --git a/packages/backend/src/graphql/mutations/tiles/delete-table.ts b/packages/backend/src/graphql/mutations/tiles/delete-table.ts index de4be2d70..7e25bee0f 100644 --- a/packages/backend/src/graphql/mutations/tiles/delete-table.ts +++ b/packages/backend/src/graphql/mutations/tiles/delete-table.ts @@ -20,6 +20,9 @@ const deleteTable: MutationResolvers['deleteTable'] = async ( .throwIfNotFound() await table.$relatedQuery('columns', trx).delete() + await TableCollaborator.query().where({ table_id: params.input.id }).patch({ + deletedAt: new Date().toISOString(), + }) await table.$query(trx).delete() }) diff --git a/packages/backend/src/graphql/mutations/verify-otp.ts b/packages/backend/src/graphql/mutations/verify-otp.ts index da057c422..db1e3f949 100644 --- a/packages/backend/src/graphql/mutations/verify-otp.ts +++ b/packages/backend/src/graphql/mutations/verify-otp.ts @@ -56,6 +56,7 @@ const verifyOtp: MutationResolvers['verifyOtp'] = async ( otpHash: null, otpAttempts: 0, otpSentAt: null, + lastLoginAt: new Date(), }) // set auth jwt as cookie diff --git a/packages/backend/src/graphql/queries/tiles/get-table-connections.ts b/packages/backend/src/graphql/queries/tiles/get-table-connections.ts new file mode 100644 index 000000000..e6c841367 --- /dev/null +++ b/packages/backend/src/graphql/queries/tiles/get-table-connections.ts @@ -0,0 +1,75 @@ +import { raw } from 'objection' + +import logger from '@/helpers/logger' +import Flow from '@/models/flow' +import Step from '@/models/step' +import TableCollaborator from '@/models/table-collaborators' + +import type { QueryResolvers } from '../../__generated__/types.generated' + +interface ExtendedFlow extends Flow { + tableid: string + count: number +} + +interface TableConnection { + [key: string]: number +} + +const getTableConnections: QueryResolvers['getTableConnections'] = async ( + _parent, + params, + context, +) => { + const { tableIds } = params + if (!tableIds) { + throw new Error('tableIds is required') + } + if (tableIds.length === 0) { + return {} + } + + try { + // get distinct rows of tables used in flows + // returns flow id and table id + // MONITOR (ogp-kevin): add index on steps.parameters->>'tableId' if query is slow + const distinctTableFlows = await Flow.query() + .innerJoin( + Step.query() + .as('tileSteps') + .select( + raw("steps.parameters->>'tableId' AS tableid"), + 'steps.flow_id', + ) + .andWhere('steps.app_key', 'tiles') + .whereNotNull(raw("steps.parameters->>'tableId'")) + .whereIn( + raw("steps.parameters->>'tableId'"), + TableCollaborator.query() + .select(raw('"table_id"::TEXT AS table_id')) // Cast to TEXT, steps.parameters is JSONB + .whereIn('table_id', tableIds) + .where('user_id', context.currentUser.id), + ), // Only include tables that the user has access to + 'flows.id', + 'tileSteps.flow_id', + ) + .select('tileSteps.tableid') + .countDistinct('flows.id') + .groupBy('tileSteps.tableid') + + const result = distinctTableFlows.reduce( + (acc: TableConnection, row: ExtendedFlow) => { + acc[row.tableid] = row.count + return acc + }, + {}, + ) + + return result + } catch (e) { + logger.error(e) + throw new Error('Error fetching table connections') + } +} + +export default getTableConnections diff --git a/packages/backend/src/graphql/queries/tiles/index.ts b/packages/backend/src/graphql/queries/tiles/index.ts index dc54883f8..029c8d4c8 100644 --- a/packages/backend/src/graphql/queries/tiles/index.ts +++ b/packages/backend/src/graphql/queries/tiles/index.ts @@ -2,6 +2,12 @@ import type { QueryResolvers } from '../../__generated__/types.generated' import getAllRows from './get-all-rows' import getTable from './get-table' +import getTableConnections from './get-table-connections' import getTables from './get-tables' -export default { getTable, getTables, getAllRows } satisfies QueryResolvers +export default { + getTable, + getTableConnections, + getTables, + getAllRows, +} satisfies QueryResolvers diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index 7c83e7d09..231048be5 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -36,11 +36,8 @@ type Query { ): [JSONObject] # Tiles getTable(tableId: String!): TableMetadata! - getTables( - limit: Int! - offset: Int! - name: String - ): PaginatedTables! + getTableConnections(tableIds: [String!]!): JSONObject + getTables(limit: Int!, offset: Int!, name: String): PaginatedTables! # Tiles rows getAllRows(tableId: String!): Any! getCurrentUser: User diff --git a/packages/backend/src/helpers/__tests__/auth.test.ts b/packages/backend/src/helpers/__tests__/auth.test.ts index c1e508320..67892aa3b 100644 --- a/packages/backend/src/helpers/__tests__/auth.test.ts +++ b/packages/backend/src/helpers/__tests__/auth.test.ts @@ -3,7 +3,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import appConfig from '@/config/app' -import { getAdminTokenUser, parseAdminToken } from '../auth' +import { + getAdminTokenUser, + getOrCreateUser, + parseAdminToken, + updateLastLogin, +} from '../auth' + +const mockPatchWhere = vi.fn() const mocks = vi.hoisted(() => ({ whereUser: vi.fn(() => ({ @@ -11,12 +18,20 @@ const mocks = vi.hoisted(() => ({ throwIfNotFound: vi.fn(() => ({ id: 'test-user-id' })), })), })), + findOne: vi.fn(), + insertAndFetch: vi.fn(), + patch: vi.fn(() => ({ + where: mockPatchWhere, + })), })) vi.mock('@/models/user', () => ({ default: { query: vi.fn(() => ({ where: mocks.whereUser, + findOne: mocks.findOne, + insertAndFetch: mocks.insertAndFetch, + patch: mocks.patch, })), }, })) @@ -69,4 +84,122 @@ describe('Auth helpers', () => { expect(result.id).toEqual('test-user-id') }) }) + + describe('getOrCreateUser', () => { + afterEach(() => { + vi.clearAllMocks() // Clear mocks after each test + }) + + it('should return an existing user if found', async () => { + const email = 'barista@coffee.com' + const existingUser = { id: 'test-user-id', email } + + mocks.findOne.mockResolvedValueOnce(existingUser) + + const result = await getOrCreateUser(email) + + expect(mocks.findOne).toHaveBeenCalledOnce() + expect(mocks.findOne).toHaveBeenCalledWith({ email: email.toLowerCase() }) + + expect(mocks.insertAndFetch).not.toHaveBeenCalled() // Ensure no new user was created + + expect(result).toEqual(existingUser) + }) + + it('should create a new user if none exists', async () => { + const email = 'chef@kitchen.com' + const newUser = { id: 'new-user-id', email } + + mocks.findOne.mockResolvedValueOnce(null) // Simulate no user found + mocks.insertAndFetch.mockResolvedValueOnce(newUser) // Simulate new user creation + + const user = await getOrCreateUser(email) + + expect(mocks.findOne).toHaveBeenCalledOnce() + expect(mocks.findOne).toHaveBeenCalledWith({ email: email.toLowerCase() }) + + expect(mocks.insertAndFetch).toHaveBeenCalledOnce() + expect(mocks.insertAndFetch).toHaveBeenCalledWith({ + email: email.toLowerCase(), + }) + + expect(user).toEqual(newUser) + }) + + it('should trim and lowercase the email before querying', async () => { + const email = ' Barista@COFFEE.com ' + const formattedEmail = 'barista@coffee.com' + const user = { id: 'test-user-id', email: formattedEmail } + + mocks.findOne.mockResolvedValueOnce(user) + + const result = await getOrCreateUser(email) + + expect(mocks.findOne).toHaveBeenCalledOnce() + expect(mocks.findOne).toHaveBeenCalledWith({ email: formattedEmail }) + + expect(result).toEqual(user) + }) + + it('should handle errors from User.query().findOne', async () => { + const email = 'barista@coffee.com' + + mocks.findOne.mockRejectedValueOnce(new Error('Database error')) + + await expect(getOrCreateUser(email)).rejects.toThrowError( + 'Database error', + ) + + expect(mocks.findOne).toHaveBeenCalledOnce() + expect(mocks.findOne).toHaveBeenCalledWith({ email: email.toLowerCase() }) + + expect(mocks.insertAndFetch).not.toHaveBeenCalled() // Ensure no insert attempt was made + }) + + it('should handle errors from User.query().insertAndFetch', async () => { + const email = 'example@domain.com' + + mocks.findOne.mockResolvedValueOnce(null) // Simulate no user found + mocks.insertAndFetch.mockRejectedValueOnce(new Error('Insert error')) + + await expect(getOrCreateUser(email)).rejects.toThrowError('Insert error') + + expect(mocks.findOne).toHaveBeenCalledOnce() + expect(mocks.findOne).toHaveBeenCalledWith({ email: email.toLowerCase() }) + + expect(mocks.insertAndFetch).toHaveBeenCalledOnce() + expect(mocks.insertAndFetch).toHaveBeenCalledWith({ + email: email.toLowerCase(), + }) + }) + }) + + describe('updateLastLogin', () => { + afterEach(() => { + vi.clearAllMocks() // Clear mocks after each test + }) + + it('patch with correct id and date', async () => { + const userId = 'test-user-id' + mocks.patch().where.mockResolvedValueOnce(1) + + await updateLastLogin(userId) + + expect(mocks.patch).toHaveBeenCalledWith({ + lastLoginAt: expect.any(Date), + }) + expect(mocks.patch().where).toHaveBeenCalledWith({ id: userId }) + }) + + it('throws error with no user id', async () => { + await expect(updateLastLogin('')).rejects.toThrowError('User id required') + }) + + it('throws error with non-existent user id', async () => { + mocks.patch().where.mockReturnValueOnce(Promise.resolve(0)) + await expect(updateLastLogin('non-existent-id')).rejects.toThrowError( + 'No user found', + ) + }) + }) }) diff --git a/packages/backend/src/helpers/auth.ts b/packages/backend/src/helpers/auth.ts index 460526495..69c7ea832 100644 --- a/packages/backend/src/helpers/auth.ts +++ b/packages/backend/src/helpers/auth.ts @@ -61,6 +61,22 @@ export async function getOrCreateUser(email: string): Promise { return user } +export async function updateLastLogin(id: string) { + if (!id) { + throw new Error('User id required!') + } + + const updatedRows = await User.query() + .patch({ + lastLoginAt: new Date(), + }) + .where({ id }) + + if (!updatedRows) { + throw new Error('No user found') + } +} + // Admin tokens are more sensitive so we set a low max age of 5 min const ADMIN_TOKEN_MAX_AGE_SEC = 5 * 60 diff --git a/packages/backend/src/models/table-column-metadata.ts b/packages/backend/src/models/table-column-metadata.ts index bff5b9261..32a721fdc 100644 --- a/packages/backend/src/models/table-column-metadata.ts +++ b/packages/backend/src/models/table-column-metadata.ts @@ -1,4 +1,6 @@ -import { ITableColumnConfig } from '@plumber/types' +import { IGlobalVariable, ITableColumnConfig } from '@plumber/types' + +import StepError from '@/errors/step' import Base from './base' import TableMetadata from './table-metadata' @@ -34,6 +36,22 @@ class TableColumnMetadata extends Base { }, }, }) + + static getColumns = async (tableId: string, $?: IGlobalVariable) => { + const columns = await TableColumnMetadata.query().where({ + table_id: tableId, + }) + + if (columns.length === 0) { + throw new StepError( + 'Tile not found', + 'Tile may have been deleted. Please check your tile.', + $.step.position, + $.app.name, + ) + } + return columns + } } export default TableColumnMetadata diff --git a/packages/backend/src/models/user.ts b/packages/backend/src/models/user.ts index 2c697cba1..68f67c493 100644 --- a/packages/backend/src/models/user.ts +++ b/packages/backend/src/models/user.ts @@ -25,6 +25,7 @@ class User extends Base { tables?: TableMetadata[] sentFlowTransfers?: FlowTransfer[] receivedFlowTransfers?: FlowTransfer[] + lastLoginAt?: Date // for typescript support when creating TableCollaborator row in insertGraph role?: ITableCollabRole diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 51523e319..b9fd78fbf 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "1.30.0", + "version": "1.31.0", "scripts": { "dev": "wait-on tcp:3000 && vite --host --force", "build": "tsc && vite build --mode=${VITE_MODE:-prod}", diff --git a/packages/frontend/src/components/DragDropInput/index.tsx b/packages/frontend/src/components/DragDropInput/index.tsx new file mode 100644 index 000000000..27e1de9b5 --- /dev/null +++ b/packages/frontend/src/components/DragDropInput/index.tsx @@ -0,0 +1,163 @@ +import { useCallback, useState } from 'react' +import { Controller, useFormContext } from 'react-hook-form' +import Markdown from 'react-markdown' +import { FormControl, Input, Stack } from '@chakra-ui/react' +import { FormErrorMessage, FormLabel } from '@opengovsg/design-system-react' + +import FileUpload from '@/components/FileUpload' +import { usePreventDrop } from '@/hooks/useDisableDragDrop' + +const SECRET_KEY_REGEX = /^[a-zA-Z0-9/+]+={0,2}$/ + +interface DragDropInputProps { + name: string + autoComplete?: string + defaultValue?: string + description?: string + label?: string + placeholder?: string + required?: boolean +} + +function DragDropInput(props: DragDropInputProps) { + const { + name, + defaultValue, + description, + label, + placeholder, + required, + ...inputProps + } = props + const [dragging, setDragging] = useState(false) + + // Disable drag drop anywhere else + usePreventDrop() + + const { control, setError, setValue } = useFormContext() + + const processFile = useCallback( + (file: File) => { + const reader = new FileReader() + reader.onload = async (e) => { + if (!e.target) { + return + } + const text = e.target.result?.toString() + + if (!text || !SECRET_KEY_REGEX.test(text)) { + return setError( + name, + { + type: 'invalidFile', + message: 'Selected file seems to be invalid', + }, + { shouldFocus: true }, + ) + } + setValue(name, text, { shouldValidate: true }) + } + reader.readAsText(file) + }, + [name, setError, setValue], + ) + + const preventDefaults = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + } + + const handleDragEnter = (e: React.DragEvent) => { + preventDefaults(e) + setDragging(true) + } + + const handleDragOver = (e: React.DragEvent) => { + preventDefaults(e) + } + + const handleDragLeave = (e: React.DragEvent) => { + preventDefaults(e) + setDragging(false) + } + + const handleDrop = useCallback( + (e: React.DragEvent) => { + preventDefaults(e) + setDragging(false) + + const file = e.dataTransfer.files?.[0] + if (!file) { + return + } + + processFile(file) + }, + [processFile], + ) + + return ( + { + return ( + <> + + {label && ( + {description} + ) + } + > + {label} + + )} + + controllerOnChange(...args)} + onDragEnter={handleDragEnter} + onDragLeave={handleDragLeave} + onDragOver={handleDragOver} + onDrop={handleDrop} + placeholder={ + dragging + ? 'Drop your file here' + : placeholder ?? 'Enter or drop your file here' + } + transition="padding 0.2s ease-out" + /> + + + {error && {error?.message}} + + + ) + }} + /> + ) +} + +export default DragDropInput diff --git a/packages/frontend/src/components/FileUpload/index.tsx b/packages/frontend/src/components/FileUpload/index.tsx new file mode 100644 index 000000000..80c01b73b --- /dev/null +++ b/packages/frontend/src/components/FileUpload/index.tsx @@ -0,0 +1,51 @@ +import { useCallback, useRef } from 'react' +import { BiUpload } from 'react-icons/bi' +import { Input } from '@chakra-ui/react' +import { IconButton } from '@opengovsg/design-system-react' + +interface FileUploadProps { + accept?: string + processFile?: (file: File) => void +} + +export default function FileUpload(props: FileUploadProps) { + const { accept, processFile } = props + const fileUploadRef = useRef(null) + + const onFileChange = useCallback( + ({ target }: React.ChangeEvent) => { + const file = target.files?.[0] + // Reset file input so the same file selected will trigger this onChange + // function. + if (fileUploadRef.current) { + fileUploadRef.current.value = '' + } + + if (!file) { + return + } + + processFile?.(file) + }, + [processFile], + ) + + return ( + <> + + } + onClick={() => fileUploadRef.current?.click()} + /> + + ) +} diff --git a/packages/frontend/src/components/InputCreator/index.tsx b/packages/frontend/src/components/InputCreator/index.tsx index edeb05239..0a3c1464c 100644 --- a/packages/frontend/src/components/InputCreator/index.tsx +++ b/packages/frontend/src/components/InputCreator/index.tsx @@ -1,6 +1,7 @@ import type { IField, IFieldDropdownOption } from '@plumber/types' import ControlledAutocomplete from '@/components/ControlledAutocomplete' +import DragDropInput from '@/components/DragDropInput' import MultiRow from '@/components/MultiRow' import MultiSelect from '@/components/MultiSelect' import RichTextEditor from '@/components/RichTextEditor' @@ -69,6 +70,19 @@ export default function InputCreator(props: InputCreatorProps): JSX.Element { ) } + if (type === 'dragdrop') { + return ( + + ) + } + if (type === 'dropdown') { const preparedOptions = schema.options || optionGenerator(data) return ( diff --git a/packages/frontend/src/components/RichTextEditor/index.tsx b/packages/frontend/src/components/RichTextEditor/index.tsx index bca242c32..8df62e52c 100644 --- a/packages/frontend/src/components/RichTextEditor/index.tsx +++ b/packages/frontend/src/components/RichTextEditor/index.tsx @@ -283,7 +283,11 @@ const RichTextEditor = ({ return ( {label && ( - + {label} )} diff --git a/packages/frontend/src/exports/components.ts b/packages/frontend/src/exports/components.ts index cf7a2466f..29ca52848 100644 --- a/packages/frontend/src/exports/components.ts +++ b/packages/frontend/src/exports/components.ts @@ -15,6 +15,7 @@ export { default as ConditionalIconButton } from '../components/ConditionalIconB export { default as Container } from '../components/Container' export { default as ControlledAutocomplete } from '../components/ControlledAutocomplete' export { default as DebouncedSearchInput } from '../components/DebouncedSearchInput' +export { default as DragDropInput } from '../components/DragDropInput' export { default as EditableTypography } from '../components/EditableTypography' export { default as Editor } from '../components/Editor' export { default as EditorLayout } from '../components/EditorLayout' @@ -24,6 +25,7 @@ export { default as ExecutionHeader } from '../components/ExecutionHeader' export { default as ExecutionRow } from '../components/ExecutionRow' export { default as ExecutionStatusMenu } from '../components/ExecutionStatusMenu' export { default as ExecutionStep } from '../components/ExecutionStep' +export { default as FileUpload } from '../components/FileUpload' export { default as FlowAppIcons } from '../components/FlowAppIcons' export { default as FlowRow } from '../components/FlowRow' export { default as FlowStep } from '../components/FlowStep' diff --git a/packages/frontend/src/graphql/queries/tiles/get-table-connections.ts b/packages/frontend/src/graphql/queries/tiles/get-table-connections.ts new file mode 100644 index 000000000..d0ae3b861 --- /dev/null +++ b/packages/frontend/src/graphql/queries/tiles/get-table-connections.ts @@ -0,0 +1,7 @@ +import { gql } from '@apollo/client' + +export const GET_TABLE_CONNECTIONS = gql` + query GetTableConnections($tableIds: [String!]!) { + getTableConnections(tableIds: $tableIds) + } +` diff --git a/packages/frontend/src/hooks/useDisableDragDrop.ts b/packages/frontend/src/hooks/useDisableDragDrop.ts new file mode 100644 index 000000000..ab0cd3aa6 --- /dev/null +++ b/packages/frontend/src/hooks/useDisableDragDrop.ts @@ -0,0 +1,20 @@ +import { useCallback, useEffect } from 'react' + +const usePreventDrop = () => { + const preventDefault = useCallback((event: DragEvent) => { + event.preventDefault() + event.stopPropagation() + }, []) + + useEffect(() => { + window.addEventListener('dragover', preventDefault) + window.addEventListener('drop', preventDefault) + + return () => { + window.removeEventListener('dragover', preventDefault) + window.removeEventListener('drop', preventDefault) + } + }, [preventDefault]) +} + +export { usePreventDrop } diff --git a/packages/frontend/src/pages/Tile/components/TableHeader/ColumnHeaderCell.tsx b/packages/frontend/src/pages/Tile/components/TableHeader/ColumnHeaderCell.tsx index 060f49a35..006ad71e8 100644 --- a/packages/frontend/src/pages/Tile/components/TableHeader/ColumnHeaderCell.tsx +++ b/packages/frontend/src/pages/Tile/components/TableHeader/ColumnHeaderCell.tsx @@ -176,6 +176,7 @@ function ColumnHeaderCell({ + + + + + ) } interface TileListProps { + isConnectionsLoading: boolean + tileConnections: TileConnections tiles: TableMetadata[] } -const TileList = ({ tiles }: TileListProps): JSX.Element => { +const TileList = ({ + isConnectionsLoading, + tileConnections, + tiles, +}: TileListProps): JSX.Element => { return ( { spacing={0} > {tiles.map((tile) => ( - + ))} ) diff --git a/packages/frontend/src/pages/Tiles/components/style.ts b/packages/frontend/src/pages/Tiles/components/style.ts new file mode 100644 index 000000000..850e5d894 --- /dev/null +++ b/packages/frontend/src/pages/Tiles/components/style.ts @@ -0,0 +1,64 @@ +import { FlexProps } from '@chakra-ui/react' + +export const flexStyles = { + container: { + color: 'base.content.medium', + direction: { + base: 'column', + md: 'row', + } as FlexProps['direction'], + textStyle: 'body-2', + }, + usedInPipes: { + alignItems: 'center', + marginLeft: { base: 0, md: '-0.25em' }, + }, +} + +export const linkStyles = { + px: 8, + py: 6, + w: '100%', + justifyContent: 'space-between', + alignItems: 'center', + _hover: { + bg: 'interaction.muted.neutral.hover', + '& .hover-remove-button': { + visibility: 'visible', + }, + }, + _active: { + bg: 'interaction.muted.neutral.active', + }, +} + +export const pulsingDotStyles = { + animation: 'pulse 2s infinite', + fontSize: '3em', + marginLeft: { base: '-0.4em', md: 0 }, + marginTop: '-0.5em', + marginBottom: '-0.5em', + '@keyframes pulse': { + '0%': { opacity: 0.4 }, + '50%': { opacity: 1 }, + '100%': { opacity: 0.4 }, + }, +} + +export const tagStyles = { + colorScheme: 'secondary', + size: 'xs', + variant: 'subtle', + py: 2, + gap: 1, + pointerEvents: 'none' as const, +} + +export const textStyles = { + lastOpened: { + width: { + base: 'full', + md: '17.5em', + }, + }, +} diff --git a/packages/frontend/src/pages/Tiles/index.tsx b/packages/frontend/src/pages/Tiles/index.tsx index 552fed99f..67e5dbc6f 100644 --- a/packages/frontend/src/pages/Tiles/index.tsx +++ b/packages/frontend/src/pages/Tiles/index.tsx @@ -9,6 +9,7 @@ import NoResultFound from '@/components/NoResultFound' import PageTitle from '@/components/PageTitle' import PrimarySpinner from '@/components/PrimarySpinner' import { PaginatedTables, TableMetadata } from '@/graphql/__generated__/graphql' +import { GET_TABLE_CONNECTIONS } from '@/graphql/queries/tiles/get-table-connections' import { GET_TABLES } from '@/graphql/queries/tiles/get-tables' import { usePaginationAndFilter } from '@/hooks/usePaginationAndFilter' @@ -20,13 +21,24 @@ const TILES_TITLE = 'Tiles' const TILES_PER_PAGE = 10 +export interface TileConnections { + [key: string]: number +} interface TilesContentProps { isLoading: boolean isSearching: boolean + isConnectionsLoading: boolean tiles: TableMetadata[] + tileConnections: TileConnections } -function TilesContent({ isLoading, isSearching, tiles }: TilesContentProps) { +function TilesContent({ + isConnectionsLoading, + isLoading, + isSearching, + tileConnections, + tiles, +}: TilesContentProps) { const hasTiles = tiles.length > 0 const hasNoUserTiles = !hasTiles && !isSearching @@ -51,29 +63,56 @@ function TilesContent({ isLoading, isSearching, tiles }: TilesContentProps) { /> ) } - return + return ( + + ) } export default function Tiles(): JSX.Element { const { input, page, setSearchParams, isSearching } = usePaginationAndFilter() + const queryVars = { + limit: TILES_PER_PAGE, + offset: (page - 1) * TILES_PER_PAGE, + name: input, + } + const { data, loading } = useQuery<{ getTables: PaginatedTables }>(GET_TABLES, { - variables: { - limit: TILES_PER_PAGE, - offset: (page - 1) * TILES_PER_PAGE, - name: input, - }, + variables: queryVars, }) - const { pageInfo, edges } = data?.getTables ?? {} - const tilesToDisplay = edges?.map(({ node }) => node) ?? [] + const { pageInfo, edges = [] } = data?.getTables ?? {} + + const { tilesToDisplay, tableIds } = edges.reduce<{ + tilesToDisplay: TableMetadata[] + tableIds: string[] + }>( + (acc, { node }) => { + acc.tilesToDisplay.push(node) + acc.tableIds.push(node.id) + return acc + }, + { tilesToDisplay: [], tableIds: [] }, + ) const hasNoUserTiles = tilesToDisplay.length === 0 && !isSearching const totalCount: number = pageInfo?.totalCount ?? 0 const hasPagination = !loading && pageInfo && totalCount > TILES_PER_PAGE + const { data: connectionsData, loading: isConnectionsLoading } = useQuery<{ + getTableConnections: JSON + }>(GET_TABLE_CONNECTIONS, { + variables: { tableIds }, + skip: hasNoUserTiles, + }) + const tileConnections = connectionsData?.getTableConnections ?? {} + // ensure invalid pages won't be accessed even after deleting tiles const lastPage = Math.ceil(totalCount / TILES_PER_PAGE) useEffect(() => { @@ -99,8 +138,10 @@ export default function Tiles(): JSX.Element { /> diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index 7a6a1cb98..0859d1dce 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -371,6 +371,14 @@ export interface IFieldRichText extends IBaseField { value?: string } +export interface IFieldDragDrop extends IBaseField { + type: 'dragdrop' + value?: string + + // Not applicable if field has variables. + autoComplete?: AutoCompleteValue +} + export interface IFieldBooleanRadio extends IBaseField { type: 'boolean-radio' value?: boolean // will default to null if not provided @@ -396,6 +404,7 @@ export type IField = | IFieldMultiRow | IFieldRichText | IFieldBooleanRadio + | IFieldDragDrop export interface IAuthenticationStepField { name: string diff --git a/packages/types/package.json b/packages/types/package.json index 3542687d7..412c97f98 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -2,5 +2,5 @@ "name": "@plumber/types", "description": "Shared types for plumber", "types": "./index.d.ts", - "version": "1.30.0" + "version": "1.31.0" }