Skip to content
Merged
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
37 changes: 37 additions & 0 deletions src/pages/BusinessUnitEdit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,41 @@ describe('BusinessUnitEdit (one-document)', () => {
expect(await screen.findByRole('button', { name: /create business unit/i })).toBeInTheDocument();
expect(businessUnitService.getById).not.toHaveBeenCalled();
});

it('blocks create when the required code is missing, without calling the API', async () => {
const user = userEvent.setup();
renderAt('/business-units/new?cluster_id=c1');
// name is required too; set it so only code is missing.
await user.click(await screen.findByRole('button', { name: /unnamed business unit/i }));
await user.type(screen.getByRole('textbox', { name: /business unit name/i }), 'New BU');
await user.tab();

await user.click(await screen.findByRole('button', { name: /create business unit/i }));

expect(businessUnitService.create).not.toHaveBeenCalled();
// shown both in the error banner and inline under the Code field
expect((await screen.findAllByText(/code is required/i)).length).toBeGreaterThan(0);
});

it('creates when required fields are present', async () => {
const user = userEvent.setup();
asMock(businessUnitService.create).mockResolvedValue({ data: { id: 'bu9' } });
// license pre-check reads the cluster; no limit set → create proceeds.
asMock(clusterService.getById).mockResolvedValue({ data: { id: 'c1', max_license_bu: null } });
renderAt('/business-units/new?cluster_id=c1');

await user.click(await screen.findByRole('button', { name: /unnamed business unit/i }));
await user.type(screen.getByRole('textbox', { name: /business unit name/i }), 'New BU');
await user.tab();
await user.click(screen.getByRole('button', { name: /^set code…$/i }));
await user.type(screen.getByRole('textbox', { name: 'Code' }), 'BU9');
await user.tab();

await user.click(await screen.findByRole('button', { name: /create business unit/i }));

expect(businessUnitService.create).toHaveBeenCalledTimes(1);
expect(asMock(businessUnitService.create).mock.calls[0][0]).toMatchObject({
code: 'BU9', name: 'New BU', cluster_id: 'c1',
});
});
});
18 changes: 18 additions & 0 deletions src/pages/BusinessUnitEdit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,25 @@ const BusinessUnitEdit: React.FC = () => {
return payload;
};

// Backend requires cluster_id, code, name (is_hq/is_active always sent). Guard
// them client-side so a blank required field never fires a doomed request.
const validateRequired = (): boolean => {
const errs: Record<string, string> = {};
if (!formData.cluster_id) errs.cluster_id = 'Cluster is required';
if (!formData.code.trim()) errs.code = 'Code is required';
else errs.code = validateField('code', formData.code);
if (!formData.name.trim()) errs.name = 'Name is required';
const active = Object.fromEntries(Object.entries(errs).filter(([, v]) => v));
setFieldErrors((prev) => ({ ...prev, ...errs }));
if (Object.keys(active).length > 0) {
setError('Please fix the highlighted fields: ' + Object.values(active).join(', '));
return false;
}
return true;
};

const handleSave = async () => {
if (!validateRequired()) return;
setSaving(true);
setError('');

Expand Down
120 changes: 120 additions & 0 deletions src/pages/businessUnitEdit/BusinessUnitDocument.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import BusinessUnitDocument from './BusinessUnitDocument';
import { initialFormData } from './types';
import type { BusinessUnitFormData } from './types';

const setup = (overrides: Partial<React.ComponentProps<typeof BusinessUnitDocument>> = {}) => {
const onCommit = vi.fn();
const onValidate = vi.fn();
const noop = vi.fn();
render(
<BusinessUnitDocument
formData={initialFormData}
fieldErrors={{}}
clusterName="-"
clusters={[]}
defaultCurrency={null}
getCalculationMethodLabel={(m) => m}
canEdit
onCommit={onCommit}
onToggle={noop}
onValidate={onValidate}
onChange={noop}
onBlur={noop}
onFocus={noop}
onConfigChange={noop}
onAddConfigRow={noop}
onRemoveConfigRow={noop}
onDbFieldChange={noop}
onDbExtraChange={noop}
onAddDbExtraRow={noop}
onRemoveDbExtraRow={noop}
{...overrides}
/>,
);
return { onCommit, onValidate };
};

// Every field the one-document rewrite must keep editable. Each entry is the
// formData key plus the label it renders under. Fields that reach formData but
// have no control here can never be set on create nor changed on edit — the
// `code` regression (400 on create) was exactly this.
const EDITABLE_FIELDS: [keyof BusinessUnitFormData, string][] = [
['code', 'Code'],
['alias_name', 'Alias'],
['max_license_users', 'Max users'],
['description', 'Description'],
['hotel_name', 'Hotel name'],
['hotel_address_line1', 'Address line 1'],
['hotel_address_line2', 'Address line 2'],
['hotel_sub_district', 'Sub-district'],
['hotel_district', 'District'],
['hotel_city', 'City'],
['hotel_province', 'Province'],
['hotel_postal_code', 'Postal code'],
['hotel_country', 'Country'],
['hotel_latitude', 'Latitude'],
['hotel_longitude', 'Longitude'],
['hotel_tel', 'Phone'],
['hotel_email', 'Email'],
['company_name', 'Company'],
['company_tel', 'Company phone'],
['company_email', 'Company email'],
['company_address_line1', 'Company address line 1'],
['company_address_line2', 'Company address line 2'],
['company_sub_district', 'Company sub-district'],
['company_district', 'Company district'],
['company_city', 'Company city'],
['company_province', 'Company province'],
['company_postal_code', 'Company postal code'],
['company_country', 'Company country'],
['company_latitude', 'Company latitude'],
['company_longitude', 'Company longitude'],
['tax_no', 'Tax ID'],
['branch_no', 'Branch'],
['timezone', 'Timezone'],
['date_format', 'Date format'],
['date_time_format', 'Date-time format'],
['time_format', 'Time format'],
['long_time_format', 'Long time format'],
['short_time_format', 'Short time format'],
];

describe('BusinessUnitDocument', () => {
it.each(EDITABLE_FIELDS)('lets the user edit %s', async (name, label) => {
const user = userEvent.setup();
const { onCommit } = setup();

await user.click(screen.getByRole('button', { name: new RegExp(`^set ${label}…$`, 'i') }));
// getByLabelText resolves both <input type="text"> and type="number"; a
// duplicated label (hotel vs company) would make this throw on ambiguity.
await user.type(screen.getByLabelText(label), '12');
await user.tab();

expect(onCommit).toHaveBeenCalledWith(name, '12');
});

it('lets the user set the required code on a new business unit', async () => {
const user = userEvent.setup();
const { onCommit } = setup();

await user.click(screen.getByRole('button', { name: /set code/i }));
await user.type(screen.getByRole('textbox', { name: 'Code' }), 'HQ-01');
await user.tab();

expect(onCommit).toHaveBeenCalledWith('code', 'HQ-01');
});

it('validates the code on commit', async () => {
const user = userEvent.setup();
const { onValidate } = setup();

await user.click(screen.getByRole('button', { name: /set code/i }));
await user.type(screen.getByRole('textbox', { name: 'Code' }), 'x');
await user.tab();

expect(onValidate).toHaveBeenCalledWith('code', 'x');
});
});
28 changes: 23 additions & 5 deletions src/pages/businessUnitEdit/BusinessUnitDocument.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,9 @@ export default function BusinessUnitDocument(props: BusinessUnitDocumentProps) {
<div className="min-w-0 flex-1">
<HeroName value={f.name} disabled={!canEdit} onCommit={(v) => onCommit('name', v)} />
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm">
<span className="text-primary bg-primary/10 rounded px-1.5 py-0.5 font-mono text-xs font-semibold">{f.code}</span>
{f.code && (
<span className="text-primary bg-primary/10 rounded px-1.5 py-0.5 font-mono text-xs font-semibold">{f.code}</span>
)}
{clusterName && clusterName !== '-' && <span className="text-foreground/80">{clusterName}</span>}
<button
type="button"
Expand Down Expand Up @@ -201,13 +203,15 @@ export default function BusinessUnitDocument(props: BusinessUnitDocumentProps) {

{/* inline fact groups */}
<Group label="Details">
{inline('code', 'Code', { mono: true, validate: true })}
{inline('alias_name', 'Alias', { validate: true })}
{inline('cluster_id', 'Cluster', { type: 'select', options: clusterOptions })}
{inline('max_license_users', 'Max users', { type: 'number', mono: true, validate: true })}
{inline('description', 'Description', { type: 'textarea' })}
</Group>

<Group label="Location">
{inline('hotel_name', 'Hotel name')}
{inline('hotel_address_line1', 'Address line 1')}
{inline('hotel_address_line2', 'Address line 2')}
{inline('hotel_sub_district', 'Sub-district')}
Expand All @@ -225,11 +229,23 @@ export default function BusinessUnitDocument(props: BusinessUnitDocumentProps) {
{inline('hotel_email', 'Email', { type: 'email' })}
</Group>

<Group label="Company & tax">
<Group label="Company">
{inline('company_name', 'Company')}
{inline('company_address_line1', 'Address')}
{inline('company_city', 'City')}
{inline('company_country', 'Country')}
{inline('company_tel', 'Company phone', { mono: true })}
{inline('company_email', 'Company email', { type: 'email' })}
{inline('company_address_line1', 'Company address line 1')}
{inline('company_address_line2', 'Company address line 2')}
{inline('company_sub_district', 'Company sub-district')}
{inline('company_district', 'Company district')}
{inline('company_city', 'Company city')}
{inline('company_province', 'Company province')}
{inline('company_postal_code', 'Company postal code', { mono: true })}
{inline('company_country', 'Company country')}
{inline('company_latitude', 'Company latitude', { mono: true })}
{inline('company_longitude', 'Company longitude', { mono: true })}
</Group>

<Group label="Tax">
{inline('tax_no', 'Tax ID', { mono: true })}
{inline('branch_no', 'Branch', { mono: true })}
</Group>
Expand All @@ -239,6 +255,8 @@ export default function BusinessUnitDocument(props: BusinessUnitDocumentProps) {
{inline('date_format', 'Date format', { mono: true })}
{inline('date_time_format', 'Date-time format', { mono: true })}
{inline('time_format', 'Time format', { mono: true })}
{inline('long_time_format', 'Long time format', { mono: true })}
{inline('short_time_format', 'Short time format', { mono: true })}
</Group>
</Card>

Expand Down
Loading