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({
}
variant="link"
+ size="xs"
py={2}
color="interaction.critical.default"
_hover={{
diff --git a/packages/frontend/src/pages/Tile/components/TableHeader/EditColumnName.tsx b/packages/frontend/src/pages/Tile/components/TableHeader/EditColumnName.tsx
index 8e5a5a058..5931ff8ed 100644
--- a/packages/frontend/src/pages/Tile/components/TableHeader/EditColumnName.tsx
+++ b/packages/frontend/src/pages/Tile/components/TableHeader/EditColumnName.tsx
@@ -32,6 +32,8 @@ export default function EditColumnName({
{isEditingColumnName ? (
<>
e.target.select()}
size="sm"
value={newColumnName}
onChange={(e) => setNewColumnName(e.target.value)}
@@ -40,7 +42,6 @@ export default function EditColumnName({
onSave()
}
}}
- onBlur={onSave}
/>
(null)
const { isOpen, onClose, onOpen } = useDisclosure()
const { createColumns, isCreatingColumns } = useUpdateTable()
const [newColumnName, setNewColumnName] = useState('')
@@ -63,6 +64,7 @@ export default function NewColumnHeaderCell({
isOpen={isOpen}
isLazy={true}
lazyBehavior="unmount"
+ initialFocusRef={inputRef}
>
setNewColumnName(e.target.value)}
diff --git a/packages/frontend/src/pages/Tiles/components/TileList.tsx b/packages/frontend/src/pages/Tiles/components/TileList.tsx
index 3f6ad08a0..5f92d8148 100644
--- a/packages/frontend/src/pages/Tiles/components/TileList.tsx
+++ b/packages/frontend/src/pages/Tiles/components/TileList.tsx
@@ -1,17 +1,22 @@
import { MouseEvent, useCallback, useRef } from 'react'
-import { BiDotsHorizontalRounded, BiShow, BiTrash } from 'react-icons/bi'
+import { BiTrash } from 'react-icons/bi'
+import { BsDot } from 'react-icons/bs'
import { MdOutlineRemoveRedEye } from 'react-icons/md'
-import { Link, useNavigate } from 'react-router-dom'
+import { Link } from 'react-router-dom'
import { useMutation } from '@apollo/client'
import {
+ AlertDialog,
+ AlertDialogBody,
+ AlertDialogContent,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogOverlay,
Box,
+ Button,
Divider,
Flex,
Icon,
- Menu,
- MenuButton,
- MenuItem,
- MenuList,
+ Skeleton,
Text,
useDisclosure,
VStack,
@@ -24,15 +29,31 @@ import {
useToast,
} from '@opengovsg/design-system-react'
-import MenuAlertDialog from '@/components/MenuAlertDialog'
import * as URLS from '@/config/urls'
import type { TableMetadata } from '@/graphql/__generated__/graphql'
import { DELETE_TABLE } from '@/graphql/mutations/tiles/delete-table'
import { GET_TABLES } from '@/graphql/queries/tiles/get-tables'
import { toPrettyDateString } from '@/helpers/dateTime'
-const TileListItem = ({ table }: { table: TableMetadata }): JSX.Element => {
- const navigate = useNavigate()
+import { TileConnections } from '..'
+
+import {
+ flexStyles,
+ linkStyles,
+ pulsingDotStyles,
+ tagStyles,
+ textStyles,
+} from './style'
+
+const TileListItem = ({
+ isConnectionsLoading,
+ numConnections,
+ table,
+}: {
+ isConnectionsLoading: boolean
+ numConnections: number
+ table: TableMetadata
+}): JSX.Element => {
const toast = useToast()
const [deleteTable, { loading: isDeletingTable }] = useMutation(
DELETE_TABLE,
@@ -51,12 +72,6 @@ const TileListItem = ({ table }: { table: TableMetadata }): JSX.Element => {
onOpen: onDialogOpen,
onClose: onDialogClose,
} = useDisclosure()
- // need to manage state to prevent bubbling of click event
- const {
- isOpen: isMenuOpen,
- onToggle: onMenuToggle,
- onClose: onMenuClose,
- } = useDisclosure()
const onDeleteButtonClick = useCallback(
(e: MouseEvent) => {
@@ -80,92 +95,100 @@ const TileListItem = ({ table }: { table: TableMetadata }): JSX.Element => {
return (
-
+
- {table.name}
-
- Last opened {toPrettyDateString(+table.lastAccessedAt)}
-
+
+ {table.name}
+
+
+
+ Last opened {toPrettyDateString(+table.lastAccessedAt)}
+
+ {numConnections > 0 && (
+
+
+
+ Used in {numConnections} pipes
+
+
+ )}
+
{table.role === 'viewer' && (
-
+
View only
)}
-
+ )}
-
+
+
+
+ Delete Tile
+
+
+ {numConnections > 0
+ ? `Are you sure? This Tile is used in ${numConnections} pipe(s). You can't undo this action afterwards.`
+ : "Are you sure? You can't undo this action afterwards."}
+
+
+
+
+
+
+
+
+
)
}
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"
}