diff --git a/backend/src/main/kotlin/no/nav/mulighetsrommet/api/routes/TiltakstypeRoutes.kt b/backend/src/main/kotlin/no/nav/mulighetsrommet/api/routes/TiltakstypeRoutes.kt index 1db5be30f3..dd74a91709 100644 --- a/backend/src/main/kotlin/no/nav/mulighetsrommet/api/routes/TiltakstypeRoutes.kt +++ b/backend/src/main/kotlin/no/nav/mulighetsrommet/api/routes/TiltakstypeRoutes.kt @@ -2,6 +2,7 @@ package no.nav.mulighetsrommet.api.routes import io.ktor.application.call import io.ktor.features.BadRequestException +import io.ktor.http.* import io.ktor.http.HttpStatusCode import io.ktor.request.receive import io.ktor.response.respond @@ -17,14 +18,22 @@ import no.nav.mulighetsrommet.api.services.TiltakstypeService import org.koin.ktor.ext.inject // TODO: Må lage noe felles validering her etterhvert +fun Parameters.parseList(parameter: String): List { + return entries().filter { it.key == parameter }.flatMap { it.value } +} + fun Route.tiltakstypeRoutes() { val tiltakstypeService: TiltakstypeService by inject() val tiltaksgjennomforingService: TiltaksgjennomforingService by inject() get("/api/tiltakstyper") { - val tiltakstyper = tiltakstypeService.getTiltakstyper() - call.respond(tiltakstyper) + val search = call.request.queryParameters["search"] + + val innsatsgrupper = call.request.queryParameters.parseList("innsatsgrupper").map { Integer.parseInt(it) } + + val items = tiltakstypeService.getTiltakstyper(innsatsgrupper, search) + call.respond(items) } get("/api/tiltakstyper/{tiltakskode}") { runCatching { diff --git a/backend/src/main/kotlin/no/nav/mulighetsrommet/api/services/TiltakstypeService.kt b/backend/src/main/kotlin/no/nav/mulighetsrommet/api/services/TiltakstypeService.kt index f389d692de..f3ea618648 100644 --- a/backend/src/main/kotlin/no/nav/mulighetsrommet/api/services/TiltakstypeService.kt +++ b/backend/src/main/kotlin/no/nav/mulighetsrommet/api/services/TiltakstypeService.kt @@ -4,20 +4,19 @@ import no.nav.mulighetsrommet.api.database.DatabaseFactory import no.nav.mulighetsrommet.api.domain.Tiltakskode import no.nav.mulighetsrommet.api.domain.Tiltakstype import no.nav.mulighetsrommet.api.domain.TiltakstypeTable -import org.jetbrains.exposed.sql.ResultRow -import org.jetbrains.exposed.sql.SortOrder -import org.jetbrains.exposed.sql.insertAndGetId -import org.jetbrains.exposed.sql.select -import org.jetbrains.exposed.sql.selectAll -import org.jetbrains.exposed.sql.update +import org.jetbrains.exposed.sql.* class TiltakstypeService(private val db: DatabaseFactory) { - suspend fun getTiltakstyper(): List { + suspend fun getTiltakstyper(innsatsgruppe: List?, search: String?): List { val rows = db.dbQuery { val query = TiltakstypeTable .selectAll() .orderBy(TiltakstypeTable.id to SortOrder.ASC) + + innsatsgruppe?.let { query.andWhere { TiltakstypeTable.innsatsgruppeId inList it } } + search?.let { query.andWhere { TiltakstypeTable.navn like ("%$it%") } } + query.toList() } return rows.map { row -> diff --git a/backend/src/main/resources/web/openapi.yml b/backend/src/main/resources/web/openapi.yml index 51b390a1b7..dd1fe3d25f 100644 --- a/backend/src/main/resources/web/openapi.yml +++ b/backend/src/main/resources/web/openapi.yml @@ -37,6 +37,21 @@ paths: tags: - mulighetsrommet operationId: getTiltakstyper + parameters: + - in: query + name: search + schema: + type: string + description: Search for tiltakstyper + - in: query + name: innsatsgrupper + schema: + type: array + items: + type: number + description: Innsatsgruppefilter + style: form + explode: false responses: 200: description: Array of tiltakstyper. @@ -61,7 +76,7 @@ paths: schema: $ref: "#/components/schemas/Tiltakstype" 404: - description: the specified tiltakstype was not found. + description: The specified tiltakstype was not found. content: text/plain: schema: @@ -156,6 +171,18 @@ components: - beskrivelse Tiltakstype: type: object + required: + - id + - innsatsgruppe + - sanityId + - navn + - tiltakskode + - fraDato + - tilDato + - createdBy + - createdAt + - updatedBy + - updatedAt properties: id: type: integer diff --git a/frontend/mulighetsrommet-api/src/core/ApiRequestOptions.ts b/frontend/mulighetsrommet-api/src/core/ApiRequestOptions.ts index 91c49ea70e..1a1ed384f7 100644 --- a/frontend/mulighetsrommet-api/src/core/ApiRequestOptions.ts +++ b/frontend/mulighetsrommet-api/src/core/ApiRequestOptions.ts @@ -3,8 +3,7 @@ /* eslint-disable */ export type ApiRequestOptions = { readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; - readonly url: string; - readonly path?: Record; + readonly path: string; readonly cookies?: Record; readonly headers?: Record; readonly query?: Record; @@ -13,4 +12,4 @@ export type ApiRequestOptions = { readonly mediaType?: string; readonly responseHeader?: string; readonly errors?: Record; -}; \ No newline at end of file +} \ No newline at end of file diff --git a/frontend/mulighetsrommet-api/src/core/ApiResult.ts b/frontend/mulighetsrommet-api/src/core/ApiResult.ts index e1f0d6cb7c..7363469d79 100644 --- a/frontend/mulighetsrommet-api/src/core/ApiResult.ts +++ b/frontend/mulighetsrommet-api/src/core/ApiResult.ts @@ -7,4 +7,4 @@ export type ApiResult = { readonly status: number; readonly statusText: string; readonly body: any; -}; \ No newline at end of file +} \ No newline at end of file diff --git a/frontend/mulighetsrommet-api/src/core/CancelablePromise.ts b/frontend/mulighetsrommet-api/src/core/CancelablePromise.ts index e5f1b09dda..e34e9dcb4d 100644 --- a/frontend/mulighetsrommet-api/src/core/CancelablePromise.ts +++ b/frontend/mulighetsrommet-api/src/core/CancelablePromise.ts @@ -3,8 +3,8 @@ /* eslint-disable */ export class CancelError extends Error { - constructor(message: string) { - super(message); + constructor(reason: string = 'Promise was canceled') { + super(reason); this.name = 'CancelError'; } @@ -14,8 +14,7 @@ export class CancelError extends Error { } export interface OnCancel { - readonly isResolved: boolean; - readonly isRejected: boolean; + readonly isPending: boolean; readonly isCancelled: boolean; (cancelHandler: () => void): void; @@ -24,8 +23,7 @@ export interface OnCancel { export class CancelablePromise implements Promise { readonly [Symbol.toStringTag]: string; - #isResolved: boolean; - #isRejected: boolean; + #isPending: boolean; #isCancelled: boolean; readonly #cancelHandlers: (() => void)[]; readonly #promise: Promise; @@ -39,8 +37,7 @@ export class CancelablePromise implements Promise { onCancel: OnCancel ) => void ) { - this.#isResolved = false; - this.#isRejected = false; + this.#isPending = true; this.#isCancelled = false; this.#cancelHandlers = []; this.#promise = new Promise((resolve, reject) => { @@ -48,34 +45,25 @@ export class CancelablePromise implements Promise { this.#reject = reject; const onResolve = (value: T | PromiseLike): void => { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; + if (!this.#isCancelled) { + this.#isPending = false; + this.#resolve?.(value); } - this.#isResolved = true; - this.#resolve?.(value); }; const onReject = (reason?: any): void => { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#isRejected = true; + this.#isPending = false; this.#reject?.(reason); }; const onCancel = (cancelHandler: () => void): void => { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; + if (this.#isPending) { + this.#cancelHandlers.push(cancelHandler); } - this.#cancelHandlers.push(cancelHandler); }; - Object.defineProperty(onCancel, 'isResolved', { - get: (): boolean => this.#isResolved, - }); - - Object.defineProperty(onCancel, 'isRejected', { - get: (): boolean => this.#isRejected, + Object.defineProperty(onCancel, 'isPending', { + get: (): boolean => this.#isPending, }); Object.defineProperty(onCancel, 'isCancelled', { @@ -104,7 +92,7 @@ export class CancelablePromise implements Promise { } public cancel(): void { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { + if (!this.#isPending || this.#isCancelled) { return; } this.#isCancelled = true; @@ -114,12 +102,10 @@ export class CancelablePromise implements Promise { cancelHandler(); } } catch (error) { - console.warn('Cancellation threw an error', error); + this.#reject?.(error); return; } } - this.#cancelHandlers.length = 0; - this.#reject?.(new CancelError('Request aborted')); } public get isCancelled(): boolean { diff --git a/frontend/mulighetsrommet-api/src/core/OpenAPI.ts b/frontend/mulighetsrommet-api/src/core/OpenAPI.ts index d914b2d458..dc454dfd70 100644 --- a/frontend/mulighetsrommet-api/src/core/OpenAPI.ts +++ b/frontend/mulighetsrommet-api/src/core/OpenAPI.ts @@ -6,7 +6,7 @@ import type { ApiRequestOptions } from './ApiRequestOptions'; type Resolver = (options: ApiRequestOptions) => Promise; type Headers = Record; -export type OpenAPIConfig = { +type Config = { BASE: string; VERSION: string; WITH_CREDENTIALS: boolean; @@ -16,9 +16,9 @@ export type OpenAPIConfig = { PASSWORD?: string | Resolver; HEADERS?: Headers | Resolver; ENCODE_PATH?: (path: string) => string; -}; +} -export const OpenAPI: OpenAPIConfig = { +export const OpenAPI: Config = { BASE: '', VERSION: '1.0.0', WITH_CREDENTIALS: false, diff --git a/frontend/mulighetsrommet-api/src/core/request.ts b/frontend/mulighetsrommet-api/src/core/request.ts index acbb231f62..4c6e69dadd 100644 --- a/frontend/mulighetsrommet-api/src/core/request.ts +++ b/frontend/mulighetsrommet-api/src/core/request.ts @@ -6,104 +6,71 @@ import type { ApiRequestOptions } from './ApiRequestOptions'; import type { ApiResult } from './ApiResult'; import { CancelablePromise } from './CancelablePromise'; import type { OnCancel } from './CancelablePromise'; -import type { OpenAPIConfig } from './OpenAPI'; +import { OpenAPI } from './OpenAPI'; -const isDefined = (value: T | null | undefined): value is Exclude => { +function isDefined(value: T | null | undefined): value is Exclude { return value !== undefined && value !== null; -}; +} -const isString = (value: any): value is string => { +function isString(value: any): value is string { return typeof value === 'string'; -}; +} -const isStringWithValue = (value: any): value is string => { +function isStringWithValue(value: any): value is string { return isString(value) && value !== ''; -}; - -const isBlob = (value: any): value is Blob => { - return ( - typeof value === 'object' && - typeof value.type === 'string' && - typeof value.stream === 'function' && - typeof value.arrayBuffer === 'function' && - typeof value.constructor === 'function' && - typeof value.constructor.name === 'string' && - /^(Blob|File)$/.test(value.constructor.name) && - /^(Blob|File)$/.test(value[Symbol.toStringTag]) - ); -}; - -const isFormData = (value: any): value is FormData => { - return value instanceof FormData; -}; - -const base64 = (str: string): string => { +} + +function isBlob(value: any): value is Blob { + return value instanceof Blob; +} + +function base64(str: string): string { try { return btoa(str); } catch (err) { - // @ts-ignore return Buffer.from(str).toString('base64'); } -}; +} -const getQueryString = (params: Record): string => { +function getQueryString(params: Record): string { const qs: string[] = []; const append = (key: string, value: any) => { qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); }; - const process = (key: string, value: any) => { - if (isDefined(value)) { + Object.entries(params) + .filter(([_, value]) => isDefined(value)) + .forEach(([key, value]) => { if (Array.isArray(value)) { - value.forEach(v => { - process(key, v); - }); - } else if (typeof value === 'object') { - Object.entries(value).forEach(([k, v]) => { - process(`${key}[${k}]`, v); - }); + value.forEach(v => append(key, v)); } else { append(key, value); } - } - }; - - Object.entries(params).forEach(([key, value]) => { - process(key, value); - }); + }); if (qs.length > 0) { return `?${qs.join('&')}`; } return ''; -}; +} -const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { - const encoder = config.ENCODE_PATH || encodeURI; - - const path = options.url - .replace('{api-version}', config.VERSION) - .replace(/{(.*?)}/g, (substring: string, group: string) => { - if (options.path?.hasOwnProperty(group)) { - return encoder(String(options.path[group])); - } - return substring; - }); - - const url = `${config.BASE}${path}`; +function getUrl(options: ApiRequestOptions): string { + const path = OpenAPI.ENCODE_PATH ? OpenAPI.ENCODE_PATH(options.path) : options.path; + const url = `${OpenAPI.BASE}${path}`; if (options.query) { return `${url}${getQueryString(options.query)}`; } + return url; -}; +} -const getFormData = (options: ApiRequestOptions): FormData | undefined => { +function getFormData(options: ApiRequestOptions): FormData | undefined { if (options.formData) { const formData = new FormData(); - const process = (key: string, value: any) => { + const append = (key: string, value: any) => { if (isString(value) || isBlob(value)) { formData.append(key, value); } else { @@ -115,33 +82,33 @@ const getFormData = (options: ApiRequestOptions): FormData | undefined => { .filter(([_, value]) => isDefined(value)) .forEach(([key, value]) => { if (Array.isArray(value)) { - value.forEach(v => process(key, v)); + value.forEach(v => append(key, v)); } else { - process(key, value); + append(key, value); } }); return formData; } return; -}; +} type Resolver = (options: ApiRequestOptions) => Promise; -const resolve = async (options: ApiRequestOptions, resolver?: T | Resolver): Promise => { +async function resolve(options: ApiRequestOptions, resolver?: T | Resolver): Promise { if (typeof resolver === 'function') { return (resolver as Resolver)(options); } return resolver; -}; +} -const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions): Promise => { - const token = await resolve(options, config.TOKEN); - const username = await resolve(options, config.USERNAME); - const password = await resolve(options, config.PASSWORD); - const additionalHeaders = await resolve(options, config.HEADERS); +async function getHeaders(options: ApiRequestOptions): Promise { + const token = await resolve(options, OpenAPI.TOKEN); + const username = await resolve(options, OpenAPI.USERNAME); + const password = await resolve(options, OpenAPI.PASSWORD); + const additionalHeaders = await resolve(options, OpenAPI.HEADERS); - const headers = Object.entries({ + const defaultHeaders = Object.entries({ Accept: 'application/json', ...additionalHeaders, ...options.headers, @@ -152,71 +119,72 @@ const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions): Pr [key]: String(value), }), {} as Record); + const headers = new Headers(defaultHeaders); + if (isStringWithValue(token)) { - headers['Authorization'] = `Bearer ${token}`; + headers.append('Authorization', `Bearer ${token}`); } if (isStringWithValue(username) && isStringWithValue(password)) { const credentials = base64(`${username}:${password}`); - headers['Authorization'] = `Basic ${credentials}`; + headers.append('Authorization', `Basic ${credentials}`); } if (options.body) { if (options.mediaType) { - headers['Content-Type'] = options.mediaType; + headers.append('Content-Type', options.mediaType); } else if (isBlob(options.body)) { - headers['Content-Type'] = options.body.type || 'application/octet-stream'; + headers.append('Content-Type', options.body.type || 'application/octet-stream'); } else if (isString(options.body)) { - headers['Content-Type'] = 'text/plain'; - } else if (!isFormData(options.body)) { - headers['Content-Type'] = 'application/json'; + headers.append('Content-Type', 'text/plain'); + } else { + headers.append('Content-Type', 'application/json'); } } - return new Headers(headers); -}; + return headers; +} -const getRequestBody = (options: ApiRequestOptions): any => { +function getRequestBody(options: ApiRequestOptions): BodyInit | undefined { if (options.body) { if (options.mediaType?.includes('/json')) { return JSON.stringify(options.body) - } else if (isString(options.body) || isBlob(options.body) || isFormData(options.body)) { + } else if (isString(options.body) || isBlob(options.body)) { return options.body; } else { return JSON.stringify(options.body); } } return; -}; +} -export const sendRequest = async ( - config: OpenAPIConfig, +async function sendRequest( options: ApiRequestOptions, url: string, - body: any, formData: FormData | undefined, + body: BodyInit | undefined, headers: Headers, onCancel: OnCancel -): Promise => { +): Promise { const controller = new AbortController(); const request: RequestInit = { headers, - body: body ?? formData, + body: body || formData, method: options.method, signal: controller.signal, }; - if (config.WITH_CREDENTIALS) { - request.credentials = config.CREDENTIALS; + if (OpenAPI.WITH_CREDENTIALS) { + request.credentials = OpenAPI.CREDENTIALS; } onCancel(() => controller.abort()); return await fetch(url, request); -}; +} -const getResponseHeader = (response: Response, responseHeader?: string): string | undefined => { +function getResponseHeader(response: Response, responseHeader?: string): string | undefined { if (responseHeader) { const content = response.headers.get(responseHeader); if (isString(content)) { @@ -224,9 +192,9 @@ const getResponseHeader = (response: Response, responseHeader?: string): string } } return; -}; +} -const getResponseBody = async (response: Response): Promise => { +async function getResponseBody(response: Response): Promise { if (response.status !== 204) { try { const contentType = response.headers.get('Content-Type'); @@ -243,9 +211,9 @@ const getResponseBody = async (response: Response): Promise => { } } return; -}; +} -const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { +function catchErrors(options: ApiRequestOptions, result: ApiResult): void { const errors: Record = { 400: 'Bad Request', 401: 'Unauthorized', @@ -265,25 +233,24 @@ const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => if (!result.ok) { throw new ApiError(result, 'Generic Error'); } -}; +} /** - * Request method - * @param config The OpenAPI configuration object - * @param options The request options from the service + * Request using fetch client + * @param options The request options from the the service * @returns CancelablePromise * @throws ApiError */ -export const request = (config: OpenAPIConfig, options: ApiRequestOptions): CancelablePromise => { +export function request(options: ApiRequestOptions): CancelablePromise { return new CancelablePromise(async (resolve, reject, onCancel) => { try { - const url = getUrl(config, options); + const url = getUrl(options); const formData = getFormData(options); const body = getRequestBody(options); - const headers = await getHeaders(config, options); + const headers = await getHeaders(options); if (!onCancel.isCancelled) { - const response = await sendRequest(config, options, url, body, formData, headers, onCancel); + const response = await sendRequest(options, url, formData, body, headers, onCancel); const responseBody = await getResponseBody(response); const responseHeader = getResponseHeader(response, options.responseHeader); @@ -292,10 +259,10 @@ export const request = (config: OpenAPIConfig, options: ApiRequestOptions): C ok: response.ok, status: response.status, statusText: response.statusText, - body: responseHeader ?? responseBody, + body: responseHeader || responseBody, }; - catchErrorCodes(options, result); + catchErrors(options, result); resolve(result.body); } @@ -303,4 +270,4 @@ export const request = (config: OpenAPIConfig, options: ApiRequestOptions): C reject(error); } }); -}; \ No newline at end of file +} \ No newline at end of file diff --git a/frontend/mulighetsrommet-api/src/index.ts b/frontend/mulighetsrommet-api/src/index.ts index 33aa4bc3c4..468c310181 100644 --- a/frontend/mulighetsrommet-api/src/index.ts +++ b/frontend/mulighetsrommet-api/src/index.ts @@ -2,9 +2,8 @@ /* tslint:disable */ /* eslint-disable */ export { ApiError } from './core/ApiError'; -export { CancelablePromise, CancelError } from './core/CancelablePromise'; +export { CancelablePromise } from './core/CancelablePromise'; export { OpenAPI } from './core/OpenAPI'; -export type { OpenAPIConfig } from './core/OpenAPI'; export type { Innsatsgruppe } from './models/Innsatsgruppe'; export type { Tiltaksgjennomforing } from './models/Tiltaksgjennomforing'; diff --git a/frontend/mulighetsrommet-api/src/models/Innsatsgruppe.ts b/frontend/mulighetsrommet-api/src/models/Innsatsgruppe.ts index cad75013a3..071f44d09a 100644 --- a/frontend/mulighetsrommet-api/src/models/Innsatsgruppe.ts +++ b/frontend/mulighetsrommet-api/src/models/Innsatsgruppe.ts @@ -6,4 +6,4 @@ export type Innsatsgruppe = { id: number; tittel: string; beskrivelse: string; -}; +} diff --git a/frontend/mulighetsrommet-api/src/models/Tiltaksgjennomforing.ts b/frontend/mulighetsrommet-api/src/models/Tiltaksgjennomforing.ts index 5d992fd4d9..c94f05c16b 100644 --- a/frontend/mulighetsrommet-api/src/models/Tiltaksgjennomforing.ts +++ b/frontend/mulighetsrommet-api/src/models/Tiltaksgjennomforing.ts @@ -12,4 +12,4 @@ export type Tiltaksgjennomforing = { tiltaksnummer?: string; fraDato: string | null; tilDato: string | null; -}; +} diff --git a/frontend/mulighetsrommet-api/src/models/Tiltakstype.ts b/frontend/mulighetsrommet-api/src/models/Tiltakstype.ts index e2964165d1..6cc737e11f 100644 --- a/frontend/mulighetsrommet-api/src/models/Tiltakstype.ts +++ b/frontend/mulighetsrommet-api/src/models/Tiltakstype.ts @@ -2,18 +2,18 @@ /* tslint:disable */ /* eslint-disable */ -import type { Tiltakskode } from './Tiltakskode'; +import type { Tiltakskode } from "./Tiltakskode"; export type Tiltakstype = { - id?: number; - innsatsgruppe?: number | null; - sanityId?: number | null; - navn?: string; - tiltakskode?: Tiltakskode; - fraDato?: string | null; - tilDato?: string | null; - createdBy?: string | null; - createdAt?: string | null; - updatedBy?: string | null; - updatedAt?: string | null; + id: number; + innsatsgruppe: number | null; + sanityId: number | null; + navn: string; + tiltakskode: Tiltakskode; + fraDato: string | null; + tilDato: string | null; + createdBy: string | null; + createdAt: string | null; + updatedBy: string | null; + updatedAt: string | null; }; diff --git a/frontend/mulighetsrommet-api/src/services/InternalService.ts b/frontend/mulighetsrommet-api/src/services/InternalService.ts index 5852f6da9e..1977cba611 100644 --- a/frontend/mulighetsrommet-api/src/services/InternalService.ts +++ b/frontend/mulighetsrommet-api/src/services/InternalService.ts @@ -2,7 +2,6 @@ /* tslint:disable */ /* eslint-disable */ import type { CancelablePromise } from '../core/CancelablePromise'; -import { OpenAPI } from '../core/OpenAPI'; import { request as __request } from '../core/request'; export class InternalService { @@ -11,10 +10,10 @@ export class InternalService { * @returns string API is online and responding * @throws ApiError */ - public static getInternalPing(): CancelablePromise<'PONG'> { - return __request(OpenAPI, { + public static getInternal(): CancelablePromise<'PONG'> { + return __request({ method: 'GET', - url: '/internal/ping', + path: `/internal/ping`, }); } diff --git a/frontend/mulighetsrommet-api/src/services/MulighetsrommetService.ts b/frontend/mulighetsrommet-api/src/services/MulighetsrommetService.ts index 7f67aa04de..be5af218a1 100644 --- a/frontend/mulighetsrommet-api/src/services/MulighetsrommetService.ts +++ b/frontend/mulighetsrommet-api/src/services/MulighetsrommetService.ts @@ -5,9 +5,7 @@ import type { Innsatsgruppe } from '../models/Innsatsgruppe'; import type { Tiltaksgjennomforing } from '../models/Tiltaksgjennomforing'; import type { Tiltakskode } from '../models/Tiltakskode'; import type { Tiltakstype } from '../models/Tiltakstype'; - import type { CancelablePromise } from '../core/CancelablePromise'; -import { OpenAPI } from '../core/OpenAPI'; import { request as __request } from '../core/request'; export class MulighetsrommetService { @@ -17,9 +15,9 @@ export class MulighetsrommetService { * @throws ApiError */ public static getInnsatsgrupper(): CancelablePromise> { - return __request(OpenAPI, { + return __request({ method: 'GET', - url: '/api/innsatsgrupper', + path: `/api/innsatsgrupper`, }); } @@ -27,10 +25,22 @@ export class MulighetsrommetService { * @returns Tiltakstype Array of tiltakstyper. * @throws ApiError */ - public static getTiltakstyper(): CancelablePromise> { - return __request(OpenAPI, { + public static getTiltakstyper({ + search, + innsatsgrupper, + }: { + /** Search for tiltakstyper **/ + search?: string, + /** Innsatsgruppefilter **/ + innsatsgrupper?: Array, + }): CancelablePromise> { + return __request({ method: 'GET', - url: '/api/tiltakstyper', + path: `/api/tiltakstyper`, + query: { + 'search': search, + 'innsatsgrupper': innsatsgrupper, + }, }); } @@ -44,14 +54,11 @@ export class MulighetsrommetService { /** Tiltakskode **/ tiltakskode: Tiltakskode, }): CancelablePromise { - return __request(OpenAPI, { + return __request({ method: 'GET', - url: '/api/tiltakstyper/{tiltakskode}', - path: { - 'tiltakskode': tiltakskode, - }, + path: `/api/tiltakstyper/${tiltakskode}`, errors: { - 404: `the specified tiltakstype was not found.`, + 404: `The specified tiltakstype was not found.`, }, }); } @@ -66,12 +73,9 @@ export class MulighetsrommetService { /** Tiltakskode **/ tiltakskode: Tiltakskode, }): CancelablePromise> { - return __request(OpenAPI, { + return __request({ method: 'GET', - url: '/api/tiltakstyper/{tiltakskode}/tiltaksgjennomforinger', - path: { - 'tiltakskode': tiltakskode, - }, + path: `/api/tiltakstyper/${tiltakskode}/tiltaksgjennomforinger`, errors: { 404: `the specified tiltakstype was not found.`, }, @@ -83,9 +87,9 @@ export class MulighetsrommetService { * @throws ApiError */ public static getTiltaksgjennomforinger(): CancelablePromise> { - return __request(OpenAPI, { + return __request({ method: 'GET', - url: '/api/tiltaksgjennomforinger', + path: `/api/tiltaksgjennomforinger`, }); } @@ -99,12 +103,9 @@ export class MulighetsrommetService { /** ID **/ id: number, }): CancelablePromise { - return __request(OpenAPI, { + return __request({ method: 'GET', - url: '/api/tiltaksgjennomforinger/{id}', - path: { - 'id': id, - }, + path: `/api/tiltaksgjennomforinger/${id}`, errors: { 404: `The specified tiltaksgjennomføring was not found.`, }, diff --git a/frontend/mulighetsrommet-veileder-flate/cypress/fixtures/example.json b/frontend/mulighetsrommet-veileder-flate/cypress/fixtures/example.json new file mode 100644 index 0000000000..02e4254378 --- /dev/null +++ b/frontend/mulighetsrommet-veileder-flate/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/frontend/mulighetsrommet-veileder-flate/cypress/integration/mulighetsrommet_spec.js b/frontend/mulighetsrommet-veileder-flate/cypress/integration/mulighetsrommet_spec.js index ebaf3ccfd6..1d24c0c8e2 100644 --- a/frontend/mulighetsrommet-veileder-flate/cypress/integration/mulighetsrommet_spec.js +++ b/frontend/mulighetsrommet-veileder-flate/cypress/integration/mulighetsrommet_spec.js @@ -4,11 +4,27 @@ before('Start server', () => { describe('Mulighetsrommet', () => { //TODO fiks denne når ny frontend er klar - // it('Check page a11y', () => { - // cy.checkPageA11y(); - // }); + it('Sjekk at det er tiltak i listen', () => { + cy.checkPageA11y(); + cy.getByTestId('tabell_tiltakstyper').children().children().should('have.length.greaterThan', 1); + cy.getByTestId('tabell_tiltakstyper_tiltaksnummer').first().click(); - it('Testytest', () => { - cy.getByTestId('tabell_oversikt-tiltakstyper').find('tr').should('have.length.greaterThan', 1); + cy.getByTestId('main-view-header_opplaering').should('be.visible'); + cy.checkPageA11y(); + cy.getByTestId('btn_send-informasjon').click(); + + cy.get('.ReactModal__Content').should('be.visible'); + cy.getByTestId('modal_header').contains('Informasjon om Opplæring'); + cy.getByTestId('textarea_send-informasjon').type('Dette er informasjon om tiltakstypen opplæring.'); + cy.getByTestId('modal_btn-cancel').contains('Avbryt').click(); //TODO send denne istedenfor å avbryte når Grafana er oppe og går + cy.get('.ReactModal__Content').should('not.exist'); + + cy.getByTestId('btn_gi-tilbakemelding').click(); + + cy.get('.ReactModal__Content').should('be.visible'); + cy.getByTestId('modal_header').contains('Tilbakemelding'); + cy.getByTestId('textarea_tilbakemelding').type('Her kommer en kjempefin tilbakemelding trudeluuu.'); + cy.getByTestId('modal_btn-cancel').contains('Avbryt').click(); //TODO send denne istedenfor å avbryte når Grafana er oppe og går + cy.get('.ReactModal__Content').should('not.exist'); }); }); diff --git a/frontend/mulighetsrommet-veileder-flate/cypress/support/commands.js b/frontend/mulighetsrommet-veileder-flate/cypress/support/commands.js index 74b9f4f73b..7d6ea39617 100644 --- a/frontend/mulighetsrommet-veileder-flate/cypress/support/commands.js +++ b/frontend/mulighetsrommet-veileder-flate/cypress/support/commands.js @@ -34,7 +34,7 @@ Cypress.Commands.add('configure', () => { method: 'GET', url: '/', }); - cy.getByTestId('header-tiltakstyper').should('contain', 'Tiltakstyper'); + cy.getByTestId('tiltakstype-oversikt').children().should('have.length.greaterThan', 1); }); Cypress.Commands.add('getByTestId', (selector, ...args) => { diff --git a/frontend/mulighetsrommet-veileder-flate/index.html b/frontend/mulighetsrommet-veileder-flate/index.html index 04ebcb4a06..d7abca4976 100644 --- a/frontend/mulighetsrommet-veileder-flate/index.html +++ b/frontend/mulighetsrommet-veileder-flate/index.html @@ -2,11 +2,24 @@ + + Arbeidsmarkedstiltak + + + +
+ veilarbpersonflate-bilde +
diff --git a/frontend/mulighetsrommet-veileder-flate/package.json b/frontend/mulighetsrommet-veileder-flate/package.json index ec56a5f899..bc9fde2150 100644 --- a/frontend/mulighetsrommet-veileder-flate/package.json +++ b/frontend/mulighetsrommet-veileder-flate/package.json @@ -27,14 +27,15 @@ "**/fsevents": "^1.2.9" }, "dependencies": { - "@navikt/ds-css": "^0.12.14", - "@navikt/ds-icons": "^0.7.3", - "@navikt/ds-react": "^0.14.15", + "@navikt/ds-css": "^0.15.12", + "@navikt/ds-icons": "^0.8.6", + "@navikt/ds-react": "^0.17.11", "@navikt/fnrvalidator": "^1.2.0", "@navikt/frontendlogger": "^2.0.0", "@navikt/navspa": "^4.1.1", "@nutgaard/use-fetch": "^2.3.1", - "@reduxjs/toolkit": "^1.8.0", + "@radix-ui/react-tabs": "^0.1.5", + "@reduxjs/toolkit": "^1.6.1", "@types/node": "^17.0.21", "@vitejs/plugin-react": "^1.2.0", "ansi-regex": "^6.0.1", @@ -79,7 +80,7 @@ "browserslist": "^4.19.1", "craco-less": "^1.20.0", "cross-env": "^7.0.3", - "cypress": "^9.5.0", + "cypress": "^9.5.2", "cypress-axe": "^0.13.0", "eslint": "^7.32.0", "eslint-config-prettier": "^8.3.0", @@ -93,7 +94,7 @@ "faker": "^5.5.3", "msw": "^0.36.2", "postcss": "^8.4.6", - "prettier": "^2.5.1", + "prettier": "^2.6.0", "rimraf": "^3.0.2", "sass": "^1.49.9", "typescript": "^4.5.3" diff --git a/frontend/mulighetsrommet-veileder-flate/public/internflate20211122.png b/frontend/mulighetsrommet-veileder-flate/public/internflate20211122.png new file mode 100644 index 0000000000..e0609b97ad Binary files /dev/null and b/frontend/mulighetsrommet-veileder-flate/public/internflate20211122.png differ diff --git a/frontend/mulighetsrommet-veileder-flate/src/App.less b/frontend/mulighetsrommet-veileder-flate/src/App.less index 025f7561f6..d761a6eeb0 100644 --- a/frontend/mulighetsrommet-veileder-flate/src/App.less +++ b/frontend/mulighetsrommet-veileder-flate/src/App.less @@ -3,4 +3,10 @@ color: black; } -@nav_teal: #34675c; +.app { + max-width: 1920px; + min-height: 30vh; + margin: 0 auto 0; + scroll-behavior: smooth !important; + width: 100%; +} diff --git a/frontend/mulighetsrommet-veileder-flate/src/Routes.tsx b/frontend/mulighetsrommet-veileder-flate/src/Routes.tsx index 3ea9048d1f..2b267ca5a1 100644 --- a/frontend/mulighetsrommet-veileder-flate/src/Routes.tsx +++ b/frontend/mulighetsrommet-veileder-flate/src/Routes.tsx @@ -1,19 +1,13 @@ import React from 'react'; import { Route, Switch } from 'react-router-dom'; -import TiltaksgjennomforingDetaljer from './views/tiltaksgjennomforing-detaljer/TiltaksgjennomforingDetaljer'; -import TiltakstypeDetaljer from './views/tiltakstype-detaljer/TiltakstypeDetaljer'; -import TiltakstypeOversikt from './views/tiltakstype-oversikt/TiltakstypeOversikt'; +import ViewTiltakstypeDetaljer from './views/tiltakstype-detaljer/ViewTiltakstypeDetaljer'; +import ViewTiltakstypeOversikt from './views/tiltakstype-oversikt/ViewTiltakstypeOversikt'; const Routes = () => { return ( - - - + + ); }; diff --git a/frontend/mulighetsrommet-veileder-flate/src/components/decorator/Decorator.tsx b/frontend/mulighetsrommet-veileder-flate/src/components/decorator/Decorator.tsx deleted file mode 100644 index 0b15d8ae52..0000000000 --- a/frontend/mulighetsrommet-veileder-flate/src/components/decorator/Decorator.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; -import { DecoratorProps } from './DecoratorProps'; -import decoratorConfig from './DecoratorConfig'; -import Navspa from '@navikt/navspa'; - -const InternflateDecorator = Navspa.importer('internarbeidsflatefs'); - -const Decorator = () => { - const dekoratorConfig = decoratorConfig(); - return ; -}; - -export default Decorator; diff --git a/frontend/mulighetsrommet-veileder-flate/src/components/decorator/DecoratorConfig.tsx b/frontend/mulighetsrommet-veileder-flate/src/components/decorator/DecoratorConfig.tsx deleted file mode 100644 index 6544f5d8af..0000000000 --- a/frontend/mulighetsrommet-veileder-flate/src/components/decorator/DecoratorConfig.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { DecoratorProps } from './DecoratorProps'; - -const decoratorConfig = (): DecoratorProps => { - return { - appname: 'Arbeidstiltak', - toggles: { - visVeileder: true, - }, - }; -}; - -export default decoratorConfig; diff --git a/frontend/mulighetsrommet-veileder-flate/src/components/decorator/DecoratorProps.tsx b/frontend/mulighetsrommet-veileder-flate/src/components/decorator/DecoratorProps.tsx deleted file mode 100644 index b0ef4b2e8a..0000000000 --- a/frontend/mulighetsrommet-veileder-flate/src/components/decorator/DecoratorProps.tsx +++ /dev/null @@ -1,48 +0,0 @@ -export interface DecoratorProps { - appname: string; - fnr?: FnrContextvalue; - enhet?: EnhetContextvalue; - toggles?: TogglesConfig; - markup?: Markup; -} - -export interface TogglesConfig { - visVeileder?: boolean; -} - -export interface Markup { - etterSokefelt?: string; -} - -export interface ControlledContextvalue extends BaseContextvalue { - value: string | null; -} - -export interface UncontrolledContextvalue extends BaseContextvalue { - initialValue: string | null; -} - -export interface BaseContextvalue { - display: T; - skipModal?: boolean; - ignoreWsEvents?: boolean; - - onChange(value: string | null): void; -} - -export type Contextvalue = ControlledContextvalue | UncontrolledContextvalue; - -export enum EnhetDisplay { - // eslint-disable-next-line - ENHET = 'ENHET', - // eslint-disable-next-line - ENHET_VALG = 'ENHET_VALG', -} - -export enum FnrDisplay { - // eslint-disable-next-line - SOKEFELT = 'SOKEFELT', -} - -export type EnhetContextvalue = Contextvalue; -export type FnrContextvalue = Contextvalue; diff --git a/frontend/mulighetsrommet-veileder-flate/src/components/filtrering/Filtermeny.less b/frontend/mulighetsrommet-veileder-flate/src/components/filtrering/Filtermeny.less new file mode 100644 index 0000000000..7680a031e4 --- /dev/null +++ b/frontend/mulighetsrommet-veileder-flate/src/components/filtrering/Filtermeny.less @@ -0,0 +1,19 @@ +.tiltakstype-oversikt__filtermeny { + width: 15rem; + height: 100%; + position: sticky; + top: 0; + border-right: 1px solid var(--navds-semantic-color-border-muted); + padding: 1rem 0.5rem 1rem 0; + transition: top 0.2s ease 0s, max-height 0.2s ease 0s; + + display: flex; + flex-direction: column; + gap: 0.5rem; + + .filtermeny__heading { + display: flex; + justify-content: space-between; + align-items: center; + } +} diff --git a/frontend/mulighetsrommet-veileder-flate/src/components/filtrering/Filtermeny.tsx b/frontend/mulighetsrommet-veileder-flate/src/components/filtrering/Filtermeny.tsx new file mode 100644 index 0000000000..2ea52a0789 --- /dev/null +++ b/frontend/mulighetsrommet-veileder-flate/src/components/filtrering/Filtermeny.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { Heading } from '@navikt/ds-react'; +import InnsatsgruppeFilter from './InnsatsgruppeFilter'; +import './Filtermeny.less'; +import Ikonknapp from '../knapper/Ikonknapp'; +import { Close } from '@navikt/ds-icons'; +import { useAtom } from 'jotai'; +import Searchfield from './Searchfield'; +import { tiltakstypefilter } from '../../core/atoms/atoms'; + +interface SidemenyProps { + handleClickSkjulSidemeny: () => void; +} + +const Filtermeny = ({ handleClickSkjulSidemeny }: SidemenyProps) => { + const [filter, setFilter] = useAtom(tiltakstypefilter); + + return ( +
+ + Filter + + + + + setFilter({ ...filter, search })} /> + setFilter({ ...filter, innsatsgrupper })} + /> +
+ ); +}; + +export default Filtermeny; diff --git a/frontend/mulighetsrommet-veileder-flate/src/components/filtrering/InnsatsgruppeFilter.tsx b/frontend/mulighetsrommet-veileder-flate/src/components/filtrering/InnsatsgruppeFilter.tsx new file mode 100644 index 0000000000..1394c44040 --- /dev/null +++ b/frontend/mulighetsrommet-veileder-flate/src/components/filtrering/InnsatsgruppeFilter.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Accordion, Alert, Checkbox, CheckboxGroup, Loader } from '@navikt/ds-react'; +import { useInnsatsgrupper } from '../../hooks/tiltakstype/useInnsatsgrupper'; +import { Innsatsgruppe } from "../../../../mulighetsrommet-api"; + +interface InnsatsgruppeFilterProps { + innsatsgruppefilter: Innsatsgruppe[]; + setInnsatsgruppefilter: (innsatsgrupper: Innsatsgruppe[]) => void; +} + +const InnsatsgruppeFilter = ({ innsatsgruppefilter, setInnsatsgruppefilter }: InnsatsgruppeFilterProps) => { + const innsatsgrupper = useInnsatsgrupper().data; + const innsatsgrupperLoading = useInnsatsgrupper().isLoading; + const innsatsgrupperError = useInnsatsgrupper().isError; + + const valgteInnsatsgruppeIDer = innsatsgruppefilter.map(gruppe => gruppe.id); + + const handleFjernFilter = (e: React.ChangeEvent) => { + const value = parseInt(e.target.value); + const valgteInnsatsgrupper = !valgteInnsatsgruppeIDer.includes(value) + ? valgteInnsatsgruppeIDer.concat(value) + : valgteInnsatsgruppeIDer.filter(id => id !== value); + setInnsatsgruppefilter( + innsatsgrupper?.filter(innsatsgruppe => valgteInnsatsgrupper.includes(innsatsgruppe.id)) ?? [] + ); + }; + + return ( + + + Innsatsgruppe + + {innsatsgrupperLoading && } + {innsatsgrupper && ( + //TODO har bedt om endring fra designsystemet for value + + {innsatsgrupper?.map(innsatsgruppe => ( + + {innsatsgruppe.tittel} + + ))} + + )} + {innsatsgrupperError && Det har skjedd en feil...} + + + + ); +}; + +export default InnsatsgruppeFilter; diff --git a/frontend/mulighetsrommet-veileder-flate/src/components/filtrering/Searchfield.tsx b/frontend/mulighetsrommet-veileder-flate/src/components/filtrering/Searchfield.tsx new file mode 100644 index 0000000000..be752cac3b --- /dev/null +++ b/frontend/mulighetsrommet-veileder-flate/src/components/filtrering/Searchfield.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import '../../views/tiltakstype-oversikt/ViewTiltakstypeOversikt.less'; +import { TextField } from '@navikt/ds-react'; + +interface SokeFilterProps { + sokefilter: string; + setSokefilter: (sokefilter: string) => void; +} + +const Searchfield = ({ sokefilter, setSokefilter }: SokeFilterProps) => { + return ( + ) => setSokefilter(e.currentTarget.value)} + value={sokefilter} + className="sokefelt-tiltakstype" + aria-label="Søk etter tiltakstype" + /> + ); +}; + +export default Searchfield; diff --git a/frontend/mulighetsrommet-veileder-flate/src/components/filtrering/Sokefelt.tsx b/frontend/mulighetsrommet-veileder-flate/src/components/filtrering/Sokefelt.tsx deleted file mode 100644 index 6ea47c1b29..0000000000 --- a/frontend/mulighetsrommet-veileder-flate/src/components/filtrering/Sokefelt.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import '../../views/tiltakstype-oversikt/TiltakstypeOversikt.less'; -import { useAtom } from 'jotai'; -import { tiltakstypeOversiktSok } from '../../core/atoms/atoms'; -import { TextField } from '@navikt/ds-react'; - -const Sokefelt = () => { - const [sok, setSok] = useAtom(tiltakstypeOversiktSok); - return ( - setSok(e.currentTarget.value)} - value={sok} - data-testid="sokefelt_tiltakstype" - className="sokefelt-tiltakstype" - /> - ); -}; - -export default Sokefelt; diff --git a/frontend/mulighetsrommet-veileder-flate/src/components/knapper/Ikonknapp.less b/frontend/mulighetsrommet-veileder-flate/src/components/knapper/Ikonknapp.less new file mode 100644 index 0000000000..4a5176229d --- /dev/null +++ b/frontend/mulighetsrommet-veileder-flate/src/components/knapper/Ikonknapp.less @@ -0,0 +1,18 @@ +.tiltakstype-oversikt { + .ikonknapp { + width: fit-content; + color: black; + &:hover { + box-shadow: none; + } + &:focus { + box-shadow: none; + } + &:active { + background-color: transparent !important; + box-shadow: none !important; + color: var(--navds-button-color-tertiary-text); + } + } + +} diff --git a/frontend/mulighetsrommet-veileder-flate/src/components/knapper/Ikonknapp.tsx b/frontend/mulighetsrommet-veileder-flate/src/components/knapper/Ikonknapp.tsx new file mode 100644 index 0000000000..1914d7c96a --- /dev/null +++ b/frontend/mulighetsrommet-veileder-flate/src/components/knapper/Ikonknapp.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import './Ikonknapp.less'; +import { Button } from '@navikt/ds-react'; +import classNames from 'classnames'; + +interface SidemenyKnappProps { + children: React.ReactNode; + className?: string; + handleClick: () => void; + ariaLabel: string; +} + +const Ikonknapp = ({ children, ariaLabel, className, handleClick }: SidemenyKnappProps) => { + return ( + + ); +}; + +export default Ikonknapp; diff --git a/frontend/mulighetsrommet-veileder-flate/src/components/modal/SendInformasjonModal.tsx b/frontend/mulighetsrommet-veileder-flate/src/components/modal/SendInformasjonModal.tsx new file mode 100644 index 0000000000..ced6389c8c --- /dev/null +++ b/frontend/mulighetsrommet-veileder-flate/src/components/modal/SendInformasjonModal.tsx @@ -0,0 +1,37 @@ +import { BodyLong, Textarea } from '@navikt/ds-react'; +import React, { useState } from 'react'; +import './modal.less'; +import StandardModal from './StandardModal'; + +interface SendInformasjonModalProps { + modalOpen: boolean; + setModalOpen: () => void; + tiltaksnavn?: string; +} + +const SendInformasjonModal = ({ modalOpen, setModalOpen, tiltaksnavn }: SendInformasjonModalProps) => { + const [verdi, setVerdi] = useState(''); + + return ( + + + Kandidatene blir varslet på SMS/e-post, og kan se informasjon om tiltaket i aktivitetsplanen på Ditt NAV.{' '} + +