Skip to content

Commit

Permalink
Merge pull request #4814 from FlowFuse/4813
Browse files Browse the repository at this point in the history
Support Search by id in Global Search
  • Loading branch information
knolleary authored Nov 22, 2024
2 parents ffa0555 + 229fdce commit a92555d
Show file tree
Hide file tree
Showing 5 changed files with 162 additions and 38 deletions.
19 changes: 15 additions & 4 deletions forge/db/models/Application.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ module.exports = {
]
})
},
byTeam: async (teamIdOrHash, { query = null, includeInstances = false, includeApplicationDevices = false, includeInstanceStorageFlow = false, associationsLimit = null, includeApplicationSummary = false } = {}) => {
byTeam: async (teamIdOrHash, { query = null, applicationId = null, includeInstances = false, includeApplicationDevices = false, includeInstanceStorageFlow = false, associationsLimit = null, includeApplicationSummary = false } = {}) => {
let id = teamIdOrHash
if (typeof teamIdOrHash === 'string') {
id = M.Team.decodeHashid(teamIdOrHash)
Expand Down Expand Up @@ -120,13 +120,24 @@ module.exports = {
const queryObject = {
include: includes
}
if (query) {
queryObject.where = {
const queryWheres = []
if (applicationId) {
if (typeof applicationId === 'string') {
applicationId = M.Application.decodeHashid(applicationId)
}
queryWheres.push({ id: applicationId })
} else if (query) {
queryWheres.push({
[Op.or]: [
where(fn('lower', col('Application.name')), { [Op.like]: `%${query.toLowerCase()}%` }),
where(fn('lower', col('Application.description')), { [Op.like]: `%${query.toLowerCase()}%` })
]
}
})
}
if (queryWheres.length === 1) {
queryObject.where = queryWheres[0]
} else if (queryWheres.length > 1) {
queryObject.where = { [Op.and]: queryWheres }
}

if (includeApplicationSummary) {
Expand Down
7 changes: 4 additions & 3 deletions forge/db/models/Device.js
Original file line number Diff line number Diff line change
Expand Up @@ -305,16 +305,17 @@ module.exports = {
]
})
},
byTeam: async (teamIdOrHash, { query = null } = {}) => {
byTeam: async (teamIdOrHash, { query = null, deviceId = null } = {}) => {
let teamId = teamIdOrHash
if (typeof teamId === 'string') {
teamId = M.Team.decodeHashid(teamId)
}
const queryObject = {
where: { [Op.and]: [{ TeamId: teamId }] }
}

if (query) {
if (deviceId) {
queryObject.where[Op.and].push({ id: deviceId })
} else if (query) {
queryObject.where[Op.and].push({
[Op.or]: [
where(fn('lower', col('Device.name')), { [Op.like]: `%${query.toLowerCase()}%` }),
Expand Down
6 changes: 4 additions & 2 deletions forge/db/models/Project.js
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,7 @@ module.exports = {
include
})
},
byTeam: async (teamIdOrHash, { query = null, includeAssociations = true, includeSettings = false } = {}) => {
byTeam: async (teamIdOrHash, { query = null, instanceId = null, includeAssociations = true, includeSettings = false } = {}) => {
let teamId = teamIdOrHash
if (typeof teamId === 'string') {
teamId = M.Team.decodeHashid(teamId)
Expand Down Expand Up @@ -512,7 +512,9 @@ module.exports = {
include
}

if (query) {
if (instanceId) {
queryObject.where = { id: instanceId }
} else if (query) {
queryObject.where = where(
fn('lower', col('Project.name')),
{ [Op.like]: `%${query.toLowerCase()}%` }
Expand Down
90 changes: 63 additions & 27 deletions forge/routes/api/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,51 +52,87 @@ module.exports = async function (app) {
const membership = await request.session.User.getTeamMembership(teamId)
// Check user has access to this team - either admin or at least Viewer role
if (request.session.User.admin || app.hasPermission(membership, 'team:search')) {
// Now do the search
const applicationSearchPromise = app.db.models.Application.byTeam(
teamId,
{
query,
includeApplicationSummary: true
}
)
const instanceSearchPromise = app.db.models.Project.byTeam(
teamId,
{
query,
includeAssociations: false
}
)
const deviceSearchPromise = app.db.models.Device.byTeam(
teamId,
{
query
}
)
let applicationSearchPromise = Promise.resolve([])
let instanceSearchPromise = Promise.resolve([])
let deviceSearchPromise = Promise.resolve([])

// first check to see if the query is an ID
const applicationId = app.db.models.Application.decodeHashid(query)?.[0]
const deviceId = app.db.models.Device.decodeHashid(query)?.[0]
const isAppId = typeof applicationId === 'number'
const isDeviceId = typeof deviceId === 'number'
const isInstanceId = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/.test(query)

// now search for the query term in the team
if (isAppId) {
applicationSearchPromise = app.db.models.Application.byTeam(
teamId,
{
applicationId,
includeApplicationSummary: true
}
)
} else if (isInstanceId) {
instanceSearchPromise = app.db.models.Project.byTeam(
teamId,
{
instanceId: query,
includeAssociations: false
}
)
} else if (isDeviceId) {
deviceSearchPromise = app.db.models.Device.byTeam(
teamId,
{
deviceId
}
)
} else {
applicationSearchPromise = app.db.models.Application.byTeam(
teamId,
{
query,
includeApplicationSummary: true
}
)
instanceSearchPromise = app.db.models.Project.byTeam(
teamId,
{
query,
includeAssociations: false
}
)
deviceSearchPromise = app.db.models.Device.byTeam(
teamId,
{
query
}
)
}
const results = await Promise.all([
applicationSearchPromise,
instanceSearchPromise,
deviceSearchPromise
])
const rr = [
...results[0].map(application => {
...(results[0].map(application => {
return {
object: 'application',
...app.db.views.Application.applicationSummary(application, { detailed: true })
}
}),
...results[1].map(instance => {
})) || [],
...(results[1].map(instance => {
return {
object: 'instance',
...app.db.views.Project.projectSummary(instance)
}
}),
...results[2].devices.map(device => {
})) || [],
...(results[2].devices?.map(device => {
return {
object: 'device',
...app.db.views.Device.deviceSummary(device)
}
})
})) || []
]

reply.send({
Expand Down
78 changes: 76 additions & 2 deletions test/unit/forge/routes/api/search_spec.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const Sinon = require('sinon')
const should = require('should') // eslint-disable-line
const setup = require('../setup')

Expand Down Expand Up @@ -49,9 +50,9 @@ describe('Search API', function () {

TestObjects.AppOne = await app.factory.createApplication({ name: 'Application One', description: 'app-one-desc' }, TestObjects.BTeam)

const ia1abc = await app.factory.createInstance({ name: 'instance-app-one-abc' }, TestObjects.AppOne, app.stack, app.template, app.projectType, { start: false })
TestObjects.App1Instance1 = await app.factory.createInstance({ name: 'instance-app-one-abc' }, TestObjects.AppOne, app.stack, app.template, app.projectType, { start: false })
await app.factory.createInstance({ name: 'instance-app-one-def' }, TestObjects.AppOne, app.stack, app.template, app.projectType, { start: false })
await app.factory.createDevice({ name: 'device-app-one-instance-one-ghi' }, TestObjects.BTeam, ia1abc)
TestObjects.App1Instance1Device1 = await app.factory.createDevice({ name: 'device-app-one-instance-one-ghi' }, TestObjects.BTeam, TestObjects.App1Instance1)
await app.factory.createDevice({ name: 'device-app-one-abc' }, TestObjects.BTeam, null, TestObjects.AppOne)
await app.factory.createDevice({ name: 'device-app-one-def' }, TestObjects.BTeam, null, TestObjects.AppOne)

Expand All @@ -74,6 +75,10 @@ describe('Search API', function () {
await app.close()
})

afterEach(function () {
Sinon.restore()
})

async function login (username, password) {
const response = await app.inject({
method: 'POST',
Expand Down Expand Up @@ -194,6 +199,75 @@ describe('Search API', function () {
result.results[0].should.have.property('object', 'device')
})

it('search by application id returns only one application & does not query other tables', async function () {
// bob - team member
Sinon.spy(app.db.models.Application, 'byTeam')
Sinon.spy(app.db.models.Project, 'byTeam')
Sinon.spy(app.db.models.Device, 'byTeam')

const response = await search({ team: TestObjects.BTeam.hashid, query: TestObjects.AppOne.hashid }, TestObjects.tokens.bob)
response.statusCode.should.equal(200)
const result = response.json()
result.count.should.equal(1)
result.results.should.have.length(1)
result.results[0].should.have.property('object', 'application')

app.db.models.Application.byTeam.called.should.be.true()
app.db.models.Project.byTeam.called.should.be.false()
app.db.models.Device.byTeam.called.should.be.false()

// ensure query was not passed to byTeam & that applicationId was passed
const args = app.db.models.Application.byTeam.getCall(0).args
args[1].should.not.have.property('query')
args[1].should.have.property('applicationId', TestObjects.AppOne.id) // actual id, not hashid
})

it('search by instance id returns only one instance & does not query other tables', async function () {
// bob - team member
Sinon.spy(app.db.models.Application, 'byTeam')
Sinon.spy(app.db.models.Project, 'byTeam')
Sinon.spy(app.db.models.Device, 'byTeam')

const response = await search({ team: TestObjects.BTeam.hashid, query: TestObjects.App1Instance1.id }, TestObjects.tokens.bob)
response.statusCode.should.equal(200)
const result = response.json()
result.count.should.equal(1)
result.results.should.have.length(1)
result.results[0].should.have.property('object', 'instance')

app.db.models.Application.byTeam.called.should.be.false()
app.db.models.Project.byTeam.called.should.be.true()
app.db.models.Device.byTeam.called.should.be.false()

// ensure query was not passed to byTeam & that instanceId was passed
const args = app.db.models.Project.byTeam.getCall(0).args
args[1].should.not.have.property('query')
args[1].should.have.property('instanceId', TestObjects.App1Instance1.id)
})

it('search by device id returns only one device & does not query other tables', async function () {
// bob - team member
Sinon.spy(app.db.models.Application, 'byTeam')
Sinon.spy(app.db.models.Project, 'byTeam')
Sinon.spy(app.db.models.Device, 'byTeam')

const response = await search({ team: TestObjects.BTeam.hashid, query: TestObjects.App1Instance1Device1.hashid }, TestObjects.tokens.bob)
response.statusCode.should.equal(200)
const result = response.json()
result.count.should.equal(1)
result.results.should.have.length(1)
result.results[0].should.have.property('object', 'device')

app.db.models.Application.byTeam.called.should.be.false()
app.db.models.Project.byTeam.called.should.be.false()
app.db.models.Device.byTeam.called.should.be.true()

// ensure query was not passed to byTeam & that deviceId was passed
const args = app.db.models.Device.byTeam.getCall(0).args
args[1].should.not.have.property('query')
args[1].should.have.property('deviceId', TestObjects.App1Instance1Device1.id)
})

it('search with blank query returns nothing', async function () {
// bob - team member
const response = await search({ team: TestObjects.BTeam.hashid, query: '' }, TestObjects.tokens.bob)
Expand Down

0 comments on commit a92555d

Please sign in to comment.