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
4 changes: 4 additions & 0 deletions objectstack.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { defineStack } from '@objectstack/spec';
import * as objects from './src/objects/index.js';
import * as views from './src/views/index.js';
import { allHooks } from './src/hooks/index.js';
import {
PlatformAdminPosition,
Expand Down Expand Up @@ -42,6 +43,9 @@ export default defineStack({

objects: Object.values(objects),

// UI — the views each app navigates to (card 07 employer, card 08 platform/seeker).
views: Object.values(views),

// Logic — the stamps that make row-level scoping resolvable (see stamp.hook.ts).
hooks: allHooks,

Expand Down
111 changes: 111 additions & 0 deletions src/views/application.view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { defineView } from '@objectstack/spec';
import type { ListColumn, ListView } from '@objectstack/spec/ui';

/**
* Employer-side views over `ats_application` — the pipeline (card 07).
*
* `display_name` is the stored "<candidate> → <job>" mirror the stamp hook
* maintains, so it is a real column: safe to bind, sort and link on. Row
* scope is the permission set's job (row-level rules on `employer_org`),
* never a view filter — these views only decide what a page SHOWS.
*/

type ViewContainer = Parameters<typeof defineView>[0];
type ObjectListView = NonNullable<ViewContainer['listViews']>[string];

const data = { provider: 'object' as const, object: 'ats_application' };

const columns = [
{ field: 'display_name', link: true },
{ field: 'job' },
{ field: 'candidate' },
{ field: 'stage' },
{ field: 'source' },
{ field: 'applied_at' },
{ field: 'rating' },
] satisfies ListColumn[];

/** Newest applications first, on every grid. */
const sort = [{ field: 'applied_at', order: 'desc' }] satisfies ListView['sort'];

const exportOptions = { formats: ['csv', 'xlsx'] } satisfies ListView['exportOptions'];

/** What every kanban card shows. */
const cardFields = ['display_name', 'job', 'rating', 'applied_at'];

/**
* One inbox per working stage. The first inbox is "new" in the employer's
* vocabulary and `applied` in the pipeline's — `stage` has no `new` value
* (DESIGN.md §02), and `applied` is where every application starts.
*/
function inbox(
label: string,
stage: 'applied' | 'screening' | 'interview' | 'offer' | 'hired',
): ObjectListView {
return {
label,
type: 'grid',
data,
columns,
sort,
exportOptions,
filter: [{ field: 'stage', operator: 'equals', value: stage }],
};
}

export const ApplicationViews = defineView({
name: 'ats_application',
label: 'Applications',
object: 'ats_application',

list: {
// The default list registers as `<object>.<name>` and implicitly claims
// `default` when unnamed, which is the key the form below owns. Naming it
// keeps `ats_application.default` for the form instead of a renamed
// `default_2` with a collision warning.
name: 'all',
label: 'All Applications',
type: 'grid',
data,
columns,
sort,
exportOptions,
},

listViews: {
/** The board: one lane per `stage` option, in option order. */
pipeline: {
label: 'Pipeline',
type: 'kanban',
data,
columns: cardFields,
kanban: {
groupByField: 'stage',
columns: cardFields,
},
sort,
},

inbox_new: inbox('Inbox · New', 'applied'),
inbox_screening: inbox('Inbox · Screening', 'screening'),
inbox_interview: inbox('Inbox · Interview', 'interview'),
inbox_offer: inbox('Inbox · Offer', 'offer'),
inbox_hired: inbox('Inbox · Hired', 'hired'),
},

formViews: {
default: {
type: 'simple',
data,
columns: 2,
sections: [
{
name: 'application',
label: 'Application',
columns: 2,
fields: ['job', 'candidate', 'stage', 'source', 'rating', 'cover_letter'],
},
],
},
},
});
64 changes: 64 additions & 0 deletions src/views/candidate.view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { defineView } from '@objectstack/spec';
import type { ListColumn } from '@objectstack/spec/ui';

/**
* The talent pool (card 07). There is no talent-pool object on purpose
* (DESIGN.md §02): it is `ats_candidate` seen through these two views. Which
* candidates an employer may see at all is the permission set's decision;
* the contact and salary fields stay masked by field-level security.
*/

const data = { provider: 'object' as const, object: 'ats_candidate' };

const columns = [
{ field: 'full_name', link: true },
{ field: 'city' },
{ field: 'current_title' },
{ field: 'experience_years' },
{ field: 'skills' },
{ field: 'seeking_status' },
] satisfies ListColumn[];

export const CandidateViews = defineView({
name: 'ats_candidate',
label: 'Candidates',
object: 'ats_candidate',

listViews: {
/** Grid with the end-user filter bar on skills, city and experience. */
talent_pool: {
label: 'Talent Pool',
type: 'grid',
data,
columns,
userFilters: {
element: 'dropdown',
fields: [
{ field: 'skills' },
{ field: 'city' },
{ field: 'experience_years' },
],
},
sort: [{ field: 'full_name', order: 'asc' }],
exportOptions: { formats: ['csv', 'xlsx'] },
},

/**
* Card deck: photo as cover, name as title. The gallery config has no
* subtitle key — the first `visibleFields` entry (`current_title`) is the
* line under the title.
*/
gallery: {
label: 'Gallery',
type: 'gallery',
data,
columns: ['full_name', 'current_title', 'city', 'seeking_status'],
gallery: {
coverField: 'avatar',
titleField: 'full_name',
visibleFields: ['current_title', 'city', 'seeking_status'],
},
sort: [{ field: 'full_name', order: 'asc' }],
},
},
});
5 changes: 5 additions & 0 deletions src/views/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Employer views (card 07). Platform and seeker views extend these files (card 08).
export { ApplicationViews } from './application.view.js';
export { InterviewViews } from './interview.view.js';
export { CandidateViews } from './candidate.view.js';
export { JobViews } from './job.view.js';
59 changes: 59 additions & 0 deletions src/views/interview.view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { defineView } from '@objectstack/spec';
import type { ListColumn, ListView } from '@objectstack/spec/ui';

/**
* Employer-side views over `ats_interview` (card 07). `scheduled_at` is the
* calendar anchor; `display_name` is the stored "<candidate> · R<round>"
* mirror, so the event title is a real column.
*/

const data = { provider: 'object' as const, object: 'ats_interview' };

const columns = [
{ field: 'display_name', link: true },
{ field: 'round' },
{ field: 'scheduled_at' },
{ field: 'mode' },
{ field: 'interviewers' },
{ field: 'status' },
{ field: 'rating' },
] satisfies ListColumn[];

const exportOptions = { formats: ['csv', 'xlsx'] } satisfies ListView['exportOptions'];

export const InterviewViews = defineView({
name: 'ats_interview',
label: 'Interviews',
object: 'ats_interview',

list: {
// Named so the `default` key stays free for a form view (see application.view.ts).
name: 'all',
label: 'All Interviews',
type: 'grid',
data,
columns,
sort: [{ field: 'scheduled_at', order: 'desc' }],
exportOptions,
},

listViews: {
/**
* The interview calendar. Events are single-instant (`duration_minutes`
* is a number, not an end timestamp, so there is no `endDateField`);
* colour follows the `status` option colours.
*/
calendar: {
label: 'Interview Calendar',
type: 'calendar',
data,
columns,
calendar: {
startDateField: 'scheduled_at',
titleField: 'display_name',
colorField: 'status',
},
sort: [{ field: 'scheduled_at', order: 'asc' }],
},
},
});
52 changes: 52 additions & 0 deletions src/views/job.view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { defineView } from '@objectstack/spec';
import type { ListColumn, ListView } from '@objectstack/spec/ui';

/**
* Employer-side views over `ats_job` (card 07). `mine` carries no filter of
* its own: which employer's jobs a caller sees is the row-level rule on
* `employer_org` (both employer permission sets scope `ats_job` to the
* caller's organizations), and a view filter is presentation, not security.
*/

const data = { provider: 'object' as const, object: 'ats_job' };

const columns = [
{ field: 'title', link: true },
{ field: 'status' },
{ field: 'city' },
{ field: 'employment_type' },
{ field: 'headcount' },
{ field: 'published_at' },
{ field: 'expires_at' },
] satisfies ListColumn[];

const sort = [{ field: 'published_at', order: 'desc' }] satisfies ListView['sort'];

const exportOptions = { formats: ['csv', 'xlsx'] } satisfies ListView['exportOptions'];

export const JobViews = defineView({
name: 'ats_job',
label: 'Jobs',
object: 'ats_job',

listViews: {
mine: {
label: 'My Jobs',
type: 'grid',
data,
columns,
sort,
exportOptions,
},

published: {
label: 'Published Jobs',
type: 'grid',
data,
columns,
filter: [{ field: 'status', operator: 'equals', value: 'published' }],
sort,
exportOptions,
},
},
});