Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,9 @@ export function AdvancedIndexColumn({
/>
);

case "qrLabelApplied":
return <QrLabelAppliedColumn appliedAt={item.qrLabelAppliedAt} />;

case "status":
return (
<StatusColumn
Expand Down Expand Up @@ -422,6 +425,18 @@ function StatusColumn({
);
}

function QrLabelAppliedColumn({
appliedAt,
}: {
appliedAt: AdvancedIndexAsset["qrLabelAppliedAt"];
}) {
return (
<Td className="w-full max-w-none whitespace-nowrap">
{appliedAt ? <DateS date={appliedAt} includeTime /> : <EmptyTableValue />}
</Td>
);
}

/**
* Displays a truncated plain-text preview of the asset description and shows
* the full markdown-rendered content inside a tooltip on hover.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ import type { Column } from "~/modules/asset-index-settings/helpers";
import { getUIFieldType } from "./helpers";

describe("getUIFieldType", () => {
it("treats qrLabelApplied as a boolean field", () => {
const qrLabelColumn = {
name: "qrLabelApplied",
visible: true,
position: 0,
} as unknown as Column;

expect(getUIFieldType({ column: qrLabelColumn })).toBe("boolean");
expect(getUIFieldType({ column: qrLabelColumn, friendlyName: true })).toBe(
"Yes/No"
);
});

it("treats updatedAt as a date field", () => {
const updatedColumn = {
name: "updatedAt",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export function getUIFieldType({
fieldType = "number";
break;
case "availableToBook":
case "qrLabelApplied":
fieldType = "boolean";
break;
case "createdAt":
Expand Down
39 changes: 38 additions & 1 deletion apps/webapp/app/modules/asset-index-settings/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import { describe, expect, it } from "vitest";

import { columnsLabelsMap, defaultFields, fixedFields } from "./helpers";
import {
appendMissingDefaultFields,
columnsLabelsMap,
defaultFields,
fixedFields,
type Column,
} from "./helpers";

describe("asset index column metadata", () => {
it("registers QR label assignment as a fixed field with a user-facing label", () => {
expect(fixedFields).toContain("qrLabelApplied");
expect(columnsLabelsMap.qrLabelApplied).toBe("Has ID Assigned");
});

it("registers last updated as a fixed field with a label", () => {
expect(fixedFields).toContain("updatedAt");
expect(columnsLabelsMap.updatedAt).toBe("Updated at");
Expand All @@ -15,9 +26,13 @@ describe("asset index column metadata", () => {
const updatedColumn = defaultFields.find(
(column) => column.name === "updatedAt"
);
const qrLabelColumn = defaultFields.find(
(column) => column.name === "qrLabelApplied"
);

expect(createdColumn?.visible).toBe(true);
expect(updatedColumn).toEqual(expect.objectContaining({ visible: true }));
expect(qrLabelColumn).toEqual(expect.objectContaining({ visible: false }));
expect(updatedColumn && createdColumn).toBeTruthy();
expect(updatedColumn?.position).toBe((createdColumn?.position ?? -1) + 1);

Expand All @@ -26,3 +41,25 @@ describe("asset index column metadata", () => {
expect(positions).toEqual(expectedPositions);
});
});

describe("appendMissingDefaultFields", () => {
it("preserves legacy positions and appends new defaults without collisions", () => {
const legacyColumns: Column[] = defaultFields
.filter(({ name }) => name !== "qrLabelApplied")
.map((column, position) => ({ ...column, position }));

const result = appendMissingDefaultFields(legacyColumns, [
"qrLabelApplied",
]);

expect(result.slice(0, legacyColumns.length)).toEqual(legacyColumns);
expect(result.at(-1)).toEqual({
name: "qrLabelApplied",
visible: false,
position: legacyColumns.length,
});
expect(new Set(result.map(({ position }) => position)).size).toBe(
result.length
);
});
});
51 changes: 34 additions & 17 deletions apps/webapp/app/modules/asset-index-settings/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const fixedFields = [
"id",
"sequentialId",
"qrId",
"qrLabelApplied",
"status",
"description",
"valuation",
Expand Down Expand Up @@ -67,6 +68,7 @@ export const columnsLabelsMap: { [key in ColumnLabelKey]: string } = {
id: "ID",
sequentialId: "Asset ID",
qrId: "QR ID",
qrLabelApplied: "Has ID Assigned",
name: "Name",
status: "Status",
description: "Description",
Expand Down Expand Up @@ -98,25 +100,40 @@ export const defaultFields: Column[] = [
{ name: "id", visible: false, position: 0 },
{ name: "sequentialId", visible: true, position: 1 },
{ name: "qrId", visible: true, position: 2 },
{ name: "status", visible: true, position: 3 },
{ name: "description", visible: true, position: 4 },
{ name: "valuation", visible: true, position: 5 },
{ name: "availableToBook", visible: true, position: 6 },
{ name: "createdAt", visible: true, position: 7 },
{ name: "updatedAt", visible: true, position: 8 },
{ name: "category", visible: true, position: 9 },
{ name: "tags", visible: true, position: 10 },
{ name: "location", visible: true, position: 11 },
{ name: "kit", visible: true, position: 12 },
{ name: "custody", visible: true, position: 13 },
{ name: "upcomingReminder", visible: true, position: 14 },
{ name: "actions", visible: true, position: 15 },
{ name: "upcomingBookings", visible: true, position: 16 },
{ name: "quantity", visible: false, position: 17 },
{ name: "type", visible: false, position: 18 },
{ name: "assetModel", visible: false, position: 19 },
{ name: "qrLabelApplied", visible: false, position: 3 },
{ name: "status", visible: true, position: 4 },
{ name: "description", visible: true, position: 5 },
{ name: "valuation", visible: true, position: 6 },
{ name: "availableToBook", visible: true, position: 7 },
{ name: "createdAt", visible: true, position: 8 },
{ name: "updatedAt", visible: true, position: 9 },
{ name: "category", visible: true, position: 10 },
{ name: "tags", visible: true, position: 11 },
{ name: "location", visible: true, position: 12 },
{ name: "kit", visible: true, position: 13 },
{ name: "custody", visible: true, position: 14 },
{ name: "upcomingReminder", visible: true, position: 15 },
{ name: "actions", visible: true, position: 16 },
{ name: "upcomingBookings", visible: true, position: 17 },
{ name: "quantity", visible: false, position: 18 },
{ name: "type", visible: false, position: 19 },
{ name: "assetModel", visible: false, position: 20 },
];

export function appendMissingDefaultFields(
columns: Column[],
missingFields: ColumnLabelKey[]
) {
let nextPosition =
Math.max(-1, ...columns.map((column) => column.position)) + 1;

const fieldsToAdd = defaultFields
.filter((field) => missingFields.includes(field.name))
.map((field) => ({ ...field, position: nextPosition++ }));

return [...columns, ...fieldsToAdd];
}

// Generate barcode columns when barcodes are enabled
export const generateBarcodeColumns = (): Column[] =>
barcodeFields.map((field, index) => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { db } from "~/database/db.server";
import { ShelfError, type ErrorLabel } from "~/utils/error";
import type { Column, ColumnLabelKey } from "./helpers";
import {
appendMissingDefaultFields,
barcodeFields,
defaultFields,
fixedFields,
Expand Down Expand Up @@ -460,10 +461,10 @@ async function validateColumns({

// If default fields are missing, add them from our static defaults
if (missingDefaultFields.length > 0) {
const fieldsToAdd = defaultFields.filter((field) =>
missingDefaultFields.includes(field.name)
updatedColumns = appendMissingDefaultFields(
updatedColumns,
missingDefaultFields
);
updatedColumns = [...updatedColumns, ...fieldsToAdd];
needsUpdate = true;
}

Expand Down
1 change: 1 addition & 0 deletions apps/webapp/app/modules/asset/field-type-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export function getQueryFieldType(fieldName: string): QueryFieldType {
case "quantity":
return "number";
case "availableToBook":
case "qrLabelApplied":
return "boolean";
case "createdAt":
case "updatedAt":
Expand Down
33 changes: 33 additions & 0 deletions apps/webapp/app/modules/asset/query.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,13 @@ describe("parseSortingOptions", () => {
expect(orderByClause).toContain("LPAD(SPLIT_PART");
});

it("sorts QR label assignment by the applied timestamp", () => {
const { orderByClause } = parseSortingOptions(["qrLabelApplied:desc"]);
expect(orderByClause).toContain(
'"assetQrLabelAppliedAt" desc NULLS LAST'
);
});

it("uses custody jsonb path for custody", () => {
const { orderByClause } = parseSortingOptions(["custody:desc"]);
// Regression (custody-sort no-op): the `custody` column is a jsonb
Expand Down Expand Up @@ -310,6 +317,20 @@ function getSqlString(sql: ReturnType<typeof generateWhereClause>): string {
describe("generateWhereClause - special filter values", () => {
const orgId = "test-org-id";

it("filters Has ID Assigned using the QR label timestamp", () => {
const filter: Filter = {
name: "qrLabelApplied",
type: "boolean",
operator: "is",
value: true,
};

const result = generateWhereClause(orgId, null, [filter]);
const sql = getSqlString(result);

expect(sql).toContain('a."qrLabelAppliedAt" IS NOT NULL');
});

describe("custody filter with special values", () => {
it("handles 'in-custody' with is operator (includes active bookings)", () => {
const filter: Filter = {
Expand Down Expand Up @@ -1137,6 +1158,18 @@ describe("buildAdvancedAssetsQuery", () => {
);
});

it("selects the QR label timestamp in the cheap phase for advanced sorting", () => {
const sql = getQuerySqlString(build({ sortBy: ["qrLabelApplied:desc"] }));
const cheapPhase = sql.slice(0, sql.indexOf("sorted_asset_query"));

expect(cheapPhase).toContain(
'a."qrLabelAppliedAt" AS "assetQrLabelAppliedAt"'
);
expect(sql).toContain(
'ROW_NUMBER() OVER (ORDER BY "assetQrLabelAppliedAt" desc NULLS LAST, "assetId" ASC)'
);
});

it("keeps Category/Location joins for text search even without a name sort", () => {
// The search predicate references c.name / l.name in the WHERE, so a search
// must resolve those joins (independent of any sort). `c.name ILIKE` only
Expand Down
15 changes: 14 additions & 1 deletion apps/webapp/app/modules/asset/query.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,10 @@ function addNumberFilter(whereClause: Prisma.Sql, filter: Filter): Prisma.Sql {
}

function addBooleanFilter(whereClause: Prisma.Sql, filter: Filter): Prisma.Sql {
if (filter.name === "qrLabelApplied") {
return Prisma.sql`${whereClause} AND (a."qrLabelAppliedAt" IS NOT NULL) = ${filter.value}`;
}

return Prisma.sql`${whereClause} AND a."${Prisma.raw(filter.name)}" = ${
filter.value
}`;
Expand Down Expand Up @@ -1446,7 +1450,8 @@ type DirectAssetField =
| "updatedAt"
| "availableToBook"
| "type"
| "quantity";
| "quantity"
| "qrLabelApplied";

const directAssetFields: Record<DirectAssetField, string> = {
id: "assetId",
Expand All @@ -1460,6 +1465,7 @@ const directAssetFields: Record<DirectAssetField, string> = {
availableToBook: "assetAvailableToBook",
type: "assetType",
quantity: "assetQuantity",
qrLabelApplied: "assetQrLabelAppliedAt",
};

/**
Expand Down Expand Up @@ -1624,6 +1630,8 @@ export function parseSortingOptions(sortBy: string[]): {
orderByParts.push(
`("assetValue" * "assetQuantity") ${field.direction}`
);
} else if (field.name === "qrLabelApplied") {
orderByParts.push(`"${columnName}" ${field.direction} NULLS LAST`);
} else {
// Use regular sorting for non-text columns
orderByParts.push(`"${columnName}" ${field.direction}`);
Expand Down Expand Up @@ -2021,6 +2029,8 @@ export const assetQueryFragment = (options: AssetQueryOptions = {}) => {
a.title AS "assetTitle",
a.description AS "assetDescription",
a."sequentialId" AS "assetSequentialId",
a."qrLabelAppliedAt" AS "assetQrLabelAppliedAt",
(a."qrLabelAppliedAt" IS NOT NULL) AS "assetQrLabelApplied",
a."createdAt" AS "assetCreatedAt",
a."updatedAt" AS "assetUpdatedAt",
a."userId" AS "assetUserId",
Expand Down Expand Up @@ -2331,6 +2341,8 @@ export const assetReturnFragment = (options: AssetReturnOptions = {}) => {
'id', aq."assetId",
'sequentialId', aq."assetSequentialId",
'qrId', aq."qrId",
'qrLabelAppliedAt', aq."assetQrLabelAppliedAt",
'qrLabelApplied', aq."assetQrLabelApplied",
'title', aq."assetTitle",
'description', aq."assetDescription",
'createdAt', aq."assetCreatedAt",
Expand Down Expand Up @@ -2753,6 +2765,7 @@ export function buildAdvancedAssetsQuery({
a.status AS "assetStatus",
a.type AS "assetType",
a.description AS "assetDescription",
a."qrLabelAppliedAt" AS "assetQrLabelAppliedAt",
a."availableToBook" AS "assetAvailableToBook"${kitNameSelect}${categoryNameSelect}${assetModelNameSelect}${locationNameSelect}${qrIdSortSelect}${custodySortSelect}${barcodeSortSelects}${customFieldSelect}
${baseJoins}
${custodyJoins}
Expand Down
Loading