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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- [Fixed <kbd>Ctrl</kbd>-clicking enso:// links][14820]
- [Geo Map Visualization may display GeoSpatial data][14859]
- [Component height can be resized][14849]
- [Execution can be scheduled for the specific version tag][14883]

[14590]: https://github.com/enso-org/enso/pull/14590
[14678]: https://github.com/enso-org/enso/pull/14678
Expand All @@ -20,6 +21,7 @@
[14820]: https://github.com/enso-org/enso/pull/14820
[14859]: https://github.com/enso-org/enso/pull/14859
[14849]: https://github.com/enso-org/enso/pull/14849
[14883]: https://github.com/enso-org/enso/pull/14883

#### Enso Standard Library

Expand Down
6 changes: 6 additions & 0 deletions app/common/src/services/Backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ export interface ProjectExecutionInfo {
readonly timeZone: dateTime.IanaTimeZone
readonly maxDurationMinutes: number
readonly parallelMode: ProjectParallelMode
readonly tag: string | undefined
}

/** A specific execution schedule of a project. */
Expand Down Expand Up @@ -636,6 +637,7 @@ export interface ListSecretsResponseBody {
/** HTTP response body for the "list tag" endpoint. */
export interface ListTagsResponseBody {
readonly tags: readonly Label[]
readonly assetVersionTags: readonly string[]
}

/**
Expand Down Expand Up @@ -1075,6 +1077,8 @@ export interface S3ObjectVersion {
/** An archive containing the all the project files object in the S3 bucket. */
readonly key: string
readonly user?: OtherUser
readonly tags?: string[] | undefined
readonly comment?: string | undefined
}

/** A user other than the current user */
Expand Down Expand Up @@ -1995,6 +1999,8 @@ export abstract class Backend {
abstract createTag(body: CreateTagRequestBody): Promise<Label>
/** Return all labels accessible by the user. */
abstract listTags(): Promise<readonly Label[]>
/** Return all tags attached to asset versions in the organization. */
abstract listAssetVersionTags(): Promise<readonly string[]>
/** Set the full list of labels for a specific asset. */
abstract associateTag(
assetId: AssetId,
Expand Down
8 changes: 8 additions & 0 deletions app/common/src/services/LocalBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,14 @@ export class LocalBackend extends backend.Backend {
return Promise.resolve([])
}

/**
* Return an empty array. This function is required to be implemented as it is unconditionally
* called, but its result should never need to be used.
*/
override listAssetVersionTags(): Promise<readonly string[]> {
return Promise.resolve([])
}

/** Do nothing. This function should never need to be called. */
override associateTag() {
return Promise.resolve()
Expand Down
14 changes: 14 additions & 0 deletions app/common/src/services/RemoteBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1184,6 +1184,20 @@ export class RemoteBackend extends backend.Backend {
}
}

/**
* Return all tags attached to asset versions in the organization.
* @throws An error if a non-successful status code (not 200-299) was received.
*/
override async listAssetVersionTags(): Promise<readonly string[]> {
const path = remoteBackendPaths.LIST_TAGS_PATH
const response = await this.get<backend.ListTagsResponseBody>(path)
if (!response.ok) {
return await this.throw(response, 'listLabelsBackendError')
} else {
return (await response.json()).assetVersionTags
}
}

/**
* Set the full list of labels for a specific asset.
* @throws An error if a non-successful status code (not 200-299) was received.
Expand Down
1 change: 1 addition & 0 deletions app/common/src/text/english.json
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,7 @@
"billingPeriodOneMonth": "Monthly",
"billingPeriodOneYear": "Annually",
"newProjectExecution": "New Schedule",
"tagLabel": "With tag",
"repeatIntervalLabel": "Repeat",
"monthlyRepeatTypeLabel": "Monthly repeat type",
"firstOccurrenceLabel": "First occurrence",
Expand Down
4 changes: 2 additions & 2 deletions app/gui/integration-test/mock/cloudApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -764,7 +764,7 @@ export async function mockCloudApi(page: Page) {
})
await get(paths.LIST_TAGS_PATH, () => {
called('listTags', {})
return { tags: labels } satisfies backend.ListTagsResponseBody
return { tags: labels, assetVersionTags: [] } satisfies backend.ListTagsResponseBody
})
await get(paths.LIST_USERS_PATH, async (route) => {
called('listUsers', {})
Expand Down Expand Up @@ -799,7 +799,7 @@ export async function mockCloudApi(page: Page) {
throw new Error(`Cannot get details for a project that does not exist. Project ID: ${projectId} \n
Please make sure that you've created the project before opening it.
------------------------------------------------------------------------------------------------

Existing projects: ${Array.from(assetMap.values())
.filter((asset) => asset.type === backend.AssetType.project)
.map((asset) => asset.id)
Expand Down
27 changes: 18 additions & 9 deletions app/gui/src/dashboard/components/Dialog/DialogStackProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import invariant from 'tiny-invariant'

import type { StoreApi } from '#/utilities/zustand'
import { createStore, useStore } from '#/utilities/zustand'
import { findLastIndex } from '@/util/data/array'

/** Returns only dialog items from the full overlay stack. */
function getDialogsStack(stack: DialogStackItem[]) {
return stack.filter((stackItem) => ['dialog-fullscreen', 'dialog'].includes(stackItem.type))
}

/** DialogStackItem represents an item in the dialog stack. */
export interface DialogStackItem {
Expand Down Expand Up @@ -37,27 +43,30 @@ export function DialogStackProvider(props: React.PropsWithChildren) {

return {
stack: nextStack,
dialogsStack: nextStack.filter((stackItem) =>
['dialog-fullscreen', 'dialog'].includes(stackItem.type),
),
dialogsStack: getDialogsStack(nextStack),
}
})
},
slice: (currentId) => {
set((state) => {
const lastItem = state.stack.at(-1)
if (lastItem?.id === currentId) {
return { stack: state.stack.slice(0, -1) }
} else {
const index = findLastIndex(state.stack, (item) => item.id === currentId)
if (index == null) {
// eslint-disable-next-line no-restricted-properties
console.warn(`
DialogStackProvider: sliceFromStack: currentId ${currentId} does not match the last item in the stack. \
DialogStackProvider: sliceFromStack: currentId ${currentId} is not present in the stack. \
This is no-op but it might be a sign of a bug in the application. \
Usually, this means that the underlaying component was closed manually or the stack was not \
updated properly.
`)

return { stack: state.stack }
return state
}

const nextStack = [...state.stack.slice(0, index), ...state.stack.slice(index + 1)]

return {
stack: nextStack,
dialogsStack: getDialogsStack(nextStack),
}
})
},
Expand Down
1 change: 1 addition & 0 deletions app/gui/src/dashboard/layouts/AssetContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ export const AssetContextMenu = React.forwardRef(function AssetContextMenu(
repeat: { type: 'none' },
projectId: asset.id,
timeZone,
tag: undefined,
},
asset.title,
])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const MAX_TAG_WIDTH_CH = 32
export interface Version extends backendService.S3ObjectVersion {
readonly number: number
readonly title: string
readonly tags: readonly string[]
readonly tags: string[]
}

/** Options for duplicating an asset. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,6 @@ interface AddNewVersionVariables {
readonly placeholderId: S3ObjectVersionId
}

const MOCK_VERSION_TAGS = [
'1.24v',
'3+',
'Experimental Build Candidate',
'Feature-Flag-Rollout-Tag-Longer-Than-32-Chars',
'Customer Validation Snapshot 2026.03',
] as const

/** Display a list of previous versions of an asset. */
export function AssetVersions() {
const { remoteBackend } = useBackends()
Expand Down Expand Up @@ -91,13 +83,9 @@ function AssetVersionsInternal(props: AssetVersionsInternalProps) {
data.versions.map((version, index) => {
const number = data.versions.length - index
const title = getText('versionX', number)
// TODO[ib]: provide actual tags from the backend
// elsint-disable-next-line @typescript-eslint/no-unused-vars
// eslint-disable-next-line @typescript-eslint/naming-convention
const _mockTags = MOCK_VERSION_TAGS
const tags = [
...(version.isLatest ? [getText('latestIndicator')] : []),
//...mockTags,
...(version.tags ?? []),
]

return { ...version, number, title, tags }
Expand Down
33 changes: 22 additions & 11 deletions app/gui/src/dashboard/layouts/NewProjectExecutionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Input } from '#/components/Inputs/Input'
import { MultiSelector } from '#/components/Inputs/MultiSelector'
import { Selector } from '#/components/Inputs/Selector'
import { Text } from '#/components/Text'
import { backendMutationOptions } from '#/hooks/backendHooks'
import { backendMutationOptions, backendQueryOptions } from '#/hooks/backendHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useLocalStorageState } from '#/hooks/localStoreState'
import { useGetOrdinal } from '#/hooks/ordinalHooks'
Expand All @@ -18,6 +18,7 @@ import { useMutationCallback } from '#/utilities/tanstackQuery'
import { useText } from '$/providers/react'
import { useFeatureFlag } from '$/providers/react/featureFlags'
import { endOfMonth, getLocalTimeZone, now, toZoned, ZonedDateTime } from '@internationalized/date'
import { useQuery } from '@tanstack/react-query'
import type {
Backend,
ProjectExecutionInfo,
Expand Down Expand Up @@ -87,14 +88,15 @@ const UPSERT_EXECUTION_SCHEMA = z
.min(1)
.transform((arr) => arr.sort((a, b) => a - b))
.readonly(),
startDate: z.instanceof(ZonedDateTime).or(z.null()).optional(),
startDate: z.instanceof(ZonedDateTime).nullable().optional(),
timeZone: z.string(),
maxDurationMinutes: z
.number()
.int()
.min(MAX_DURATION_MINIMUM_MINUTES)
.max(MAX_DURATION_MAXIMUM_MINUTES),
parallelMode: z.enum(PROJECT_PARALLEL_MODES),
tag: z.string().optional(),
})
.transform(
({
Expand All @@ -106,10 +108,11 @@ const UPSERT_EXECUTION_SCHEMA = z
days,
months,
timeZone: description,
tag,
}): ProjectExecutionInfo => {
const timeZone = getTimeZoneFromDescription(description)
startDate ??= now(timeZone)
const startDateTime = toRfc3339(new Date(startDate.toAbsoluteString()))
const zonedStartDate = startDate == null ? now(timeZone) : toZoned(startDate, timeZone)
const startDateTime = toRfc3339(new Date(zonedStartDate.toAbsoluteString()))
const repeat = ((): ProjectExecutionRepeatInfo => {
switch (repeatType) {
case 'none': {
Expand All @@ -131,22 +134,22 @@ const UPSERT_EXECUTION_SCHEMA = z
case 'monthlyDate': {
return {
type: repeatType,
date: startDate.day,
date: zonedStartDate.day,
months,
}
}
case 'monthlyWeekday': {
return {
type: repeatType,
dayOfWeek: getDay(startDate),
weekNumber: getWeekOfMonth(startDate.day),
dayOfWeek: getDay(zonedStartDate),
weekNumber: getWeekOfMonth(zonedStartDate.day),
months,
}
}
case 'monthlyLastWeekday': {
return {
type: repeatType,
dayOfWeek: getDay(startDate),
dayOfWeek: getDay(zonedStartDate),
months,
}
}
Expand All @@ -160,6 +163,7 @@ const UPSERT_EXECUTION_SCHEMA = z
parallelMode,
startDate: startDateTime,
endDate: null,
tag,
}
},
)
Expand Down Expand Up @@ -221,18 +225,20 @@ export function NewProjectExecutionForm(props: NewProjectExecutionFormProps) {
days: DAYS,
months: MONTHS,
timeZone: timeZoneDescription,
tag: undefined,
},
onSubmit: async (values) => {
await createProjectExecution([values, item.title])
},
})
const repeatType = form.watch('repeatType', 'daily')
const parallelMode = form.watch('parallelMode', 'restart')
const date = form.watch('startDate', defaultStartDate) ?? defaultStartDate
const startDateValue = form.watch('startDate', defaultStartDate) ?? defaultStartDate
// `timeZone` may be `null`.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const formTimeZoneDescription = form.watch('timeZone', timeZoneDescription) ?? timeZoneDescription
const formTimeZone = getTimeZoneFromDescription(formTimeZoneDescription)
const date = toZoned(startDateValue, formTimeZone)
// Reactively watch for `days` and `months` so that repeat dates are kept up to date.
form.watch('days')
form.watch('months')
Expand All @@ -242,10 +248,10 @@ export function NewProjectExecutionForm(props: NewProjectExecutionFormProps) {
PROJECT_EXECUTION_REPEAT_TYPES.filter((type) => type !== 'monthlyLastWeekday')
: PROJECT_EXECUTION_REPEAT_TYPES

const changeTimezoneDeps = useSyncRef({ date, form })
const changeTimezoneDeps = useSyncRef({ form, startDateValue })
useEffect(() => {
const deps = changeTimezoneDeps.current
deps.form.setValue('startDate', toZoned(deps.date, formTimeZone))
deps.form.setValue('startDate', toZoned(deps.startDateValue, formTimeZone))
}, [formTimeZone, changeTimezoneDeps])

useEffect(() => {
Expand All @@ -262,6 +268,8 @@ export function NewProjectExecutionForm(props: NewProjectExecutionFormProps) {
}
})

const { data: tags } = useQuery(backendQueryOptions(backend, 'listAssetVersionTags', []))

const createProjectExecution = useMutationCallback(
backendMutationOptions(backend, 'createProjectExecution'),
)
Expand Down Expand Up @@ -310,6 +318,9 @@ export function NewProjectExecutionForm(props: NewProjectExecutionFormProps) {

return (
<Form form={form} className="w-full">
<ComboBox form={form} name="tag" label={getText('tagLabel')} items={tags ?? []}>
{(tag) => tag}
</ComboBox>
<ComboBox
form={form}
isRequired
Expand Down
Loading